├── .github ├── CODEOWNERS └── workflows │ └── release.yml ├── .gitignore ├── .rustfmt.toml ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── build └── windows │ ├── main.wxs │ └── resources │ ├── Banner.bmp │ ├── Dialog.bmp │ └── hop.ico ├── scripts ├── build.ps1 ├── bump.sh └── install.sh └── src ├── commands ├── auth │ ├── docker.rs │ ├── list.rs │ ├── login │ │ ├── browser_auth.rs │ │ ├── flags_auth.rs │ │ ├── mod.rs │ │ ├── types.rs │ │ └── util.rs │ ├── logout.rs │ ├── mod.rs │ ├── switch.rs │ ├── types.rs │ └── utils.rs ├── channels │ ├── create.rs │ ├── delete.rs │ ├── list.rs │ ├── message.rs │ ├── mod.rs │ ├── subscribe.rs │ ├── tokens │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── list.rs │ │ ├── messages.rs │ │ ├── mod.rs │ │ ├── types.rs │ │ └── utils.rs │ ├── types.rs │ └── utils.rs ├── completions │ └── mod.rs ├── containers │ ├── create.rs │ ├── delete.rs │ ├── inspect.rs │ ├── list.rs │ ├── logs.rs │ ├── metrics.rs │ ├── mod.rs │ ├── recreate.rs │ ├── types.rs │ └── utils.rs ├── deploy │ ├── builder │ │ ├── mod.rs │ │ ├── types.rs │ │ └── util.rs │ ├── local │ │ ├── mod.rs │ │ ├── types.rs │ │ └── util.rs │ └── mod.rs ├── domains │ ├── attach.rs │ ├── delete.rs │ ├── list.rs │ ├── mod.rs │ ├── types.rs │ └── util.rs ├── gateways │ ├── create.rs │ ├── delete.rs │ ├── list.rs │ ├── mod.rs │ ├── types.rs │ ├── update.rs │ └── util.rs ├── ignite │ ├── builds │ │ ├── cancel.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ ├── types.rs │ │ └── utils.rs │ ├── create.rs │ ├── delete.rs │ ├── from_compose │ │ ├── mod.rs │ │ ├── types.rs │ │ └── utils.rs │ ├── get_env.rs │ ├── groups │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ ├── move.rs │ │ └── utils.rs │ ├── health │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ ├── state.rs │ │ ├── types.rs │ │ └── utils.rs │ ├── inspect.rs │ ├── list.rs │ ├── mod.rs │ ├── promote.rs │ ├── rollout.rs │ ├── scale.rs │ ├── templates.rs │ ├── types.rs │ ├── update.rs │ └── utils.rs ├── link │ └── mod.rs ├── mod.rs ├── oops │ └── mod.rs ├── payment │ ├── due.rs │ ├── list.rs │ ├── mod.rs │ ├── types.rs │ └── utils.rs ├── projects │ ├── create │ │ ├── mod.rs │ │ └── utils.rs │ ├── delete.rs │ ├── finance │ │ ├── mod.rs │ │ ├── types.rs │ │ └── utils.rs │ ├── info.rs │ ├── list.rs │ ├── mod.rs │ ├── switch.rs │ ├── types.rs │ └── utils.rs ├── secrets │ ├── delete.rs │ ├── list.rs │ ├── mod.rs │ ├── set.rs │ ├── types.rs │ └── utils.rs ├── tunnel │ ├── mod.rs │ ├── types.rs │ └── utils.rs ├── update │ ├── checker.rs │ ├── command.rs │ ├── mod.rs │ ├── parse.rs │ ├── types.rs │ └── util.rs ├── volumes │ ├── backup.rs │ ├── copy │ │ ├── fslike.rs │ │ ├── mod.rs │ │ └── utils.rs │ ├── delete.rs │ ├── list.rs │ ├── mkdir.rs │ ├── mod.rs │ ├── move.rs │ ├── types.rs │ └── utils.rs ├── webhooks │ ├── create.rs │ ├── delete.rs │ ├── list.rs │ ├── mod.rs │ ├── regenerate.rs │ ├── update.rs │ └── utils.rs └── whoami │ └── mod.rs ├── config.rs ├── lib.rs ├── main.rs ├── state ├── http │ ├── mod.rs │ └── types.rs └── mod.rs ├── store ├── auth.rs ├── context.rs ├── hopfile.rs ├── macros.rs ├── mod.rs └── utils.rs └── utils ├── arisu ├── mod.rs ├── shard.rs └── types.rs ├── browser.rs ├── deser.rs ├── mod.rs ├── size.rs └── sudo.rs /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pxseu -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | hop.yml 3 | .env 4 | .DS_Store 5 | docker-compose* -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | format_code_in_doc_comments = true 2 | hex_literal_case = "Upper" 3 | imports_granularity = "Module" 4 | newline_style = "Unix" 5 | normalize_comments = true 6 | normalize_doc_attributes = true 7 | reorder_impl_items = true 8 | group_imports = "StdExternalCrate" 9 | use_field_init_shorthand = true 10 | use_try_shorthand = true 11 | wrap_comments = true 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.checkOnSave.command": "clippy", 3 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hop CLI 2 | 3 | [![Build and release](https://github.com/hopinc/hop_cli/actions/workflows/release.yml/badge.svg)](https://github.com/hopinc/hop_cli/actions/workflows/release.yml) 4 | 5 | The Hop CLI allows you to interface with Hop services through your command line. It can be used as a replacement for the [Console](https://console.hop.io/). 6 | 7 | ## Installation 8 | 9 | > Any of the following will make the `hop` command available to you. 10 | 11 | ### Arch Linux 12 | 13 | Use your favorite AUR helper to install the package (e.g. paru): 14 | 15 | ```bash 16 | paru -S hop-cli 17 | ``` 18 | 19 | ### Windows 20 | 21 | Install with the [Hop Windows Installer 64bit](https://download.hop.sh/windows/x86_64) or the [Hop Windows Installer 32bit](https://download.hop.sh/windows/i686) 22 | 23 | ### Homebrew 24 | 25 | ```bash 26 | brew install hopinc/tap/hop 27 | ``` 28 | 29 | ### Linux, MacOS and FreeBSD 30 | 31 | It can be installed with our universal install script: 32 | 33 | ```bash 34 | curl -fsSL https://download.hop.sh/install | sh 35 | ``` 36 | 37 | ### Source 38 | 39 | To build the application from the source code, you'll first need to install [Rust](https://www.rust-lang.org/tools/install). Then, once you've cloned the repository, you can execute this command within the directory: 40 | 41 | ```bash 42 | cargo install --path . 43 | ``` 44 | 45 | ## Logging In 46 | 47 | To get started, you need to log in to your Hop account through the CLI: 48 | 49 | ```bash 50 | hop auth login 51 | ``` 52 | 53 | A browser window will open the Hop Console and prompt you to allow the CLI to connect to your account. Once done, you will be redirected back. 54 | 55 | That's all! You can now start using the CLI. 56 | 57 | ## Usage 58 | 59 | ### Projects 60 | 61 | You can set a default project to use which will automatically be applied to every command. 62 | 63 | ```bash 64 | hop projects switch 65 | ``` 66 | 67 | You can override it by passing the `--project` argument. For example, `hop deploy --project api`. 68 | 69 | ### Deploying 70 | 71 | To deploy a project directory, first navigate to the directory through `cd` and then execute: 72 | 73 | ```bash 74 | hop deploy 75 | ``` 76 | 77 | This will deploy the project to Hop, or create a new one if you don't have a Hopfile (`hop.yml`) already. 78 | 79 | ### Linking 80 | 81 | To link a project to a service, first navigate to the directory through `cd` and then execute: 82 | 83 | ```bash 84 | hop link 85 | ``` 86 | 87 | This will link the directory to the deployment and create a Hopfile (`hop.yml`). 88 | 89 | ## Contributing 90 | 91 | Contributions are welcome! Please open an issue or pull request if you find any bugs or have any suggestions. 92 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | include!("src/commands/update/parse.rs"); 2 | 3 | #[cfg(all(windows, not(debug_assertions)))] 4 | fn main() { 5 | use chrono::Datelike; 6 | use winapi::um::winnt::{LANG_ENGLISH, MAKELANGID, SUBLANG_ENGLISH_US}; 7 | use winres::VersionInfo::PRODUCTVERSION; 8 | use winres::WindowsResource; 9 | 10 | // add the resource to the executable 11 | let mut resource = WindowsResource::new(); 12 | 13 | let current_year = chrono::Utc::now().year(); 14 | 15 | resource.set("LegalCopyright", &format!("© 2020-{current_year} Hop, Inc")); 16 | resource.set("CompanyName", "Hop, Inc"); 17 | resource.set("FileDescription", "Hop CLI"); 18 | resource.set("InternalName", "Hop CLI"); 19 | resource.set("OriginalFilename", "hop.exe"); 20 | resource.set_icon("build/windows/resources/hop.ico"); 21 | resource.set_language(MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US)); 22 | 23 | // write the version to the resource 24 | let (major, minor, patch, release) = version(env!("CARGO_PKG_VERSION")).unwrap(); 25 | 26 | resource.set_version_info( 27 | PRODUCTVERSION, 28 | (major as u64) << 48 29 | | (minor as u64) << 32 30 | | (patch as u64) << 16 31 | | (release.unwrap_or(0) as u64 + 1), 32 | ); 33 | 34 | // compile the resource file 35 | resource.compile().unwrap(); 36 | 37 | // fix VCRUNTIME140.dll 38 | static_vcruntime::metabuild(); 39 | } 40 | 41 | // no need to add for non windows or debug builds 42 | #[cfg(any(not(windows), debug_assertions))] 43 | fn main() {} 44 | -------------------------------------------------------------------------------- /build/windows/resources/Banner.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hopinc/cli/6ffcb7d8fbaa70ab0e5c902ce2c99da8d110e008/build/windows/resources/Banner.bmp -------------------------------------------------------------------------------- /build/windows/resources/Dialog.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hopinc/cli/6ffcb7d8fbaa70ab0e5c902ce2c99da8d110e008/build/windows/resources/Dialog.bmp -------------------------------------------------------------------------------- /build/windows/resources/hop.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hopinc/cli/6ffcb7d8fbaa70ab0e5c902ce2c99da8d110e008/build/windows/resources/hop.ico -------------------------------------------------------------------------------- /scripts/build.ps1: -------------------------------------------------------------------------------- 1 | # powershell -ExecutionPolicy Bypass -File .\scripts\build.ps1 2 | 3 | cargo build --release --target x86_64-pc-windows-msvc --package hop-cli 4 | 5 | cargo wix -I .\build\windows\main.wxs -v --nocapture --target x86_64-pc-windows-msvc --output target/wix/hop-x86_64-pc-windows-msvc.msi --package hop-cli 6 | 7 | target/wix/hop-x86_64-pc-windows-msvc.msi -------------------------------------------------------------------------------- /scripts/bump.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | TAG=$1 9 | 10 | echo "Bumping to $TAG" 11 | 12 | sed -i "s/^version = .*/version = \"$TAG\"/" Cargo.toml 13 | 14 | sleep 10 15 | 16 | git add Cargo.* 17 | git commit -m "feat: v$TAG" -S 18 | git tag -a v$TAG -m v$TAG -s 19 | 20 | echo "Done!" 21 | echo "To push the changes and tag, run: git push --follow-tags" -------------------------------------------------------------------------------- /src/commands/auth/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::format_users; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List all authenticated users")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "Only print the IDs of the authorized users")] 12 | pub quiet: bool, 13 | } 14 | 15 | pub fn handle(options: &Options, state: &State) -> Result<()> { 16 | let users = state.auth.authorized.keys().collect::>(); 17 | 18 | ensure!(!users.is_empty(), "There are no authorized users"); 19 | 20 | if options.quiet { 21 | let ids = users 22 | .iter() 23 | .map(|d| d.as_str()) 24 | .collect::>() 25 | .join(" "); 26 | 27 | println!("{ids}"); 28 | } else { 29 | let users_fmt = format_users(&users, true); 30 | 31 | println!("{}", users_fmt.join("\n")); 32 | } 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/auth/login/browser_auth.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | 3 | use anyhow::{anyhow, Context, Result}; 4 | use hyper::{Body, Request, Response}; 5 | use tokio::sync::mpsc::Sender; 6 | 7 | use super::WEB_AUTH_URL; 8 | use crate::commands::auth::login::PAT_FALLBACK_URL; 9 | use crate::utils::browser::listen_for_callback; 10 | use crate::utils::parse_key_val; 11 | 12 | pub async fn browser_login() -> Result { 13 | let port = portpicker::pick_unused_port().with_context(|| { 14 | "Could not find an unused port. Please make sure you have at least one port available." 15 | })?; 16 | 17 | let url = format!( 18 | "{WEB_AUTH_URL}?{}", 19 | ["callback", &format!("http://localhost:{port}/")].join("=") 20 | ); 21 | 22 | // lunch a web server to handle the auth request 23 | if let Err(why) = webbrowser::open(&url) { 24 | log::error!("Could not open web a browser."); 25 | log::debug!("Error: {why}"); 26 | log::info!("Please provide a personal access token manually."); 27 | log::info!("You can create one at {PAT_FALLBACK_URL}"); 28 | 29 | // fallback to simple input 30 | dialoguer::Password::new() 31 | .with_prompt("Enter your token") 32 | .interact() 33 | .map_err(|why| anyhow!(why)) 34 | } else { 35 | log::info!("Waiting for token to be created..."); 36 | 37 | listen_for_callback(port, 2, |req, sender| { 38 | Box::pin(request_handler(req, sender)) 39 | }) 40 | .await 41 | .map_err(|why| anyhow!(why)) 42 | } 43 | } 44 | 45 | async fn request_handler( 46 | req: Request, 47 | sender: Sender, 48 | ) -> Result, Infallible> { 49 | let query = req.uri().query(); 50 | 51 | // only send if it's an actual token 52 | if let Some(query) = query { 53 | // parse the query 54 | // since pat should be a URL safe string we can just split on '=' 55 | let query: Vec<(String, String)> = query 56 | .split('&') 57 | .map(|s| parse_key_val(s).unwrap()) 58 | .collect::>(); 59 | 60 | // if query has a key called "token" 61 | if let Some(token) = query.iter().find(|(k, _)| *k == "token") { 62 | // send it to the main thread 63 | sender.send(token.1.to_string()).await.unwrap(); 64 | return Ok(Response::new("You've been authorized".into())); 65 | } 66 | } 67 | 68 | Ok(Response::builder() 69 | .status(400) 70 | .body("You're not authorized".into()) 71 | .unwrap()) 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/auth/login/mod.rs: -------------------------------------------------------------------------------- 1 | mod browser_auth; 2 | mod flags_auth; 3 | mod types; 4 | pub mod util; 5 | 6 | use anyhow::Result; 7 | use clap::Parser; 8 | 9 | use self::browser_auth::browser_login; 10 | use self::flags_auth::flags_login; 11 | use crate::state::State; 12 | use crate::store::Store; 13 | use crate::utils::in_path; 14 | 15 | const WEB_AUTH_URL: &str = "https://console.hop.io/auth/callback/cli"; 16 | const PAT_FALLBACK_URL: &str = "https://console.hop.io/settings/pats"; 17 | 18 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 19 | #[clap(about = "Login to Hop")] 20 | #[group(skip)] 21 | pub struct Options { 22 | #[clap( 23 | long, 24 | help = "Project Token or Personal Authorization Token, you can use `--token=` to take the token from stdin" 25 | )] 26 | token: Option, 27 | #[clap(long, help = "Email")] 28 | email: Option, 29 | #[clap( 30 | long, 31 | help = "Password, you can use `--password=` to take the token from stdin" 32 | )] 33 | password: Option, 34 | } 35 | 36 | pub async fn handle(options: Options, state: State) -> Result<()> { 37 | let init_token = if Options::default() != options { 38 | flags_login(options, state.http.clone()).await? 39 | } else if let Ok(env_token) = std::env::var("TOKEN") { 40 | env_token 41 | } else { 42 | browser_login().await? 43 | }; 44 | 45 | token(&init_token, state).await 46 | } 47 | 48 | pub async fn token(token: &str, mut state: State) -> Result<()> { 49 | state.login(Some(token.to_string())).await?; 50 | 51 | // safe to unwrap here 52 | let authorized = state.ctx.current.clone().unwrap(); 53 | 54 | if Some(authorized.id.clone()) == state.ctx.default_user { 55 | log::info!( 56 | "Nothing was changed. You are already logged in as: `{}` ({})", 57 | authorized.name, 58 | authorized.email 59 | ); 60 | } else { 61 | // output the login info 62 | log::info!("Logged in as: `{}` ({})", authorized.name, authorized.email); 63 | 64 | state.ctx.default_project = authorized.projects.first().map(|x| x.id.clone()); 65 | state.ctx.default_user = Some(authorized.id.clone()); 66 | state.ctx.save().await?; 67 | } 68 | 69 | // save the state 70 | state 71 | .auth 72 | .authorized 73 | .insert(authorized.id.clone(), token.to_string()); 74 | state.auth.save().await?; 75 | 76 | if !state.is_ci 77 | && in_path("docker").await 78 | && dialoguer::Confirm::new() 79 | .with_prompt("Docker was detected, would you like to login to the Hop registry?") 80 | .default(false) 81 | .interact()? 82 | { 83 | super::docker::login_new(&authorized.email, token).await?; 84 | } 85 | 86 | Ok(()) 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/auth/login/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize)] 4 | pub struct LoginRequest { 5 | pub email: String, 6 | pub password: String, 7 | } 8 | 9 | #[derive(Debug, Deserialize, PartialEq)] 10 | #[serde(rename_all = "lowercase")] 11 | pub enum KeyType { 12 | Key, 13 | Totp, 14 | } 15 | 16 | #[derive(Debug, Deserialize)] 17 | #[serde(untagged)] 18 | pub enum LoginResponse { 19 | Success { 20 | token: String, 21 | }, 22 | SecondFactorRequired { 23 | ticket: String, 24 | preferred_type: KeyType, 25 | types: Vec, 26 | }, 27 | } 28 | 29 | #[derive(Debug, Serialize)] 30 | #[serde(untagged)] 31 | pub enum SecondFactorRequest { 32 | Totp { code: String, ticket: String }, 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/auth/login/util.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use anyhow::{anyhow, bail, Context, Result}; 4 | use serde::Deserialize; 5 | 6 | use crate::commands::auth::types::{AuthorizedClient, UserMe}; 7 | use crate::commands::projects::types::ThisProjectResponse; 8 | use crate::state::http::HttpClient; 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub enum TokenType { 12 | #[serde(rename = "PAT")] 13 | Pat, 14 | #[serde(rename = "PTK")] 15 | Ptk, 16 | #[serde(rename = "BEARER")] 17 | Bearer, 18 | } 19 | 20 | impl FromStr for TokenType { 21 | type Err = anyhow::Error; 22 | 23 | fn from_str(s: &str) -> Result { 24 | serde_json::from_str(&format!("\"{}\"", s.to_uppercase())) 25 | .map_err(|_| anyhow!("Could not parse token type: {}", s)) 26 | } 27 | } 28 | 29 | impl TokenType { 30 | pub fn from_token(token: &str) -> Result { 31 | Self::from_str(&token.split('_').next().unwrap_or("").to_uppercase()) 32 | } 33 | } 34 | 35 | pub async fn token_options( 36 | http: HttpClient, 37 | token_type: Option, 38 | ) -> Result { 39 | match token_type { 40 | // bearer token works the same as pat 41 | Some(TokenType::Pat | TokenType::Bearer) => login_pat(http.clone()).await, 42 | 43 | // ptks only allow one project at a time so diff route 44 | Some(TokenType::Ptk) => login_ptk(http.clone()).await, 45 | // should be impossible to get here 46 | token => { 47 | bail!("Unsupported token type: {token:?}"); 48 | } 49 | } 50 | } 51 | 52 | async fn login_pat(http: HttpClient) -> Result { 53 | let response = http 54 | .request::("GET", "/users/@me", None) 55 | .await? 56 | .context("Error while parsing response")?; 57 | 58 | Ok(AuthorizedClient { 59 | id: response.user.id, 60 | name: response.user.name, 61 | projects: response.projects, 62 | leap_token: response.leap_token, 63 | email: response.user.email, 64 | email_verified: response.user.email_verified, 65 | }) 66 | } 67 | 68 | async fn login_ptk(http: HttpClient) -> Result { 69 | let ThisProjectResponse { 70 | leap_token, 71 | project, 72 | } = http 73 | .request("GET", "/projects/@this", None) 74 | .await? 75 | .context("Error while parsing response")?; 76 | 77 | Ok(AuthorizedClient { 78 | projects: vec![project.clone()], 79 | name: project.name, 80 | id: project.id, 81 | leap_token, 82 | email: "user@hop.io".to_string(), 83 | email_verified: true, 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/auth/logout.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, ensure, Result}; 2 | use clap::Parser; 3 | use serde_json::Value; 4 | 5 | use crate::state::http::HttpClient; 6 | use crate::state::State; 7 | use crate::store::context::Context; 8 | use crate::store::Store; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Logout the current user")] 12 | #[group(skip)] 13 | pub struct Options {} 14 | 15 | pub async fn handle(_options: Options, mut state: State) -> Result<()> { 16 | let user_id = state.ctx.default_user; 17 | 18 | ensure!(user_id.is_some(), "You are not logged in."); 19 | 20 | invalidate_token( 21 | &state.http, 22 | state 23 | .auth 24 | .authorized 25 | .get(user_id.as_ref().unwrap()) 26 | .unwrap(), 27 | ) 28 | .await?; 29 | 30 | // clear all state 31 | state.ctx = Context::default(); 32 | state.ctx.save().await?; 33 | 34 | // remove the user from the store 35 | state.auth.authorized.remove(user_id.as_ref().unwrap()); 36 | state.auth.save().await?; 37 | 38 | log::info!("You have been logged out"); 39 | 40 | Ok(()) 41 | } 42 | 43 | async fn invalidate_token(http: &HttpClient, token: &str) -> Result<()> { 44 | match token.split('_').next() { 45 | Some("bearer") => { 46 | http.request::("POST", "/auth/logout", None).await?; 47 | } 48 | 49 | Some("pat") => { 50 | http.request::("DELETE", &format!("/users/@me/pats/{token}"), None) 51 | .await?; 52 | } 53 | 54 | Some("ptk") => { 55 | log::warn!( 56 | "Project tokens are not invalidated on logout, please revoke them manually." 57 | ); 58 | } 59 | 60 | _ => { 61 | return Err(anyhow!("Unknown token type")); 62 | } 63 | } 64 | 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/auth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod docker; 2 | mod list; 3 | pub mod login; 4 | mod logout; 5 | mod switch; 6 | pub mod types; 7 | mod utils; 8 | 9 | use anyhow::Result; 10 | use clap::{Parser, Subcommand}; 11 | 12 | use crate::state::State; 13 | 14 | #[derive(Debug, Subcommand)] 15 | pub enum Commands { 16 | #[clap(name = "ls", alias = "list")] 17 | List(list::Options), 18 | Login(login::Options), 19 | Logout(logout::Options), 20 | Switch(switch::Options), 21 | #[clap(alias = "registry")] 22 | Docker(docker::Options), 23 | } 24 | 25 | #[derive(Debug, Parser)] 26 | #[clap(about = "Authenticate with Hop")] 27 | #[group(skip)] 28 | pub struct Options { 29 | #[clap(subcommand)] 30 | pub commands: Commands, 31 | } 32 | 33 | pub async fn handle(options: Options, mut state: State) -> Result<()> { 34 | match options.commands { 35 | Commands::Login(options) => login::handle(options, state).await, 36 | Commands::Logout(options) => logout::handle(options, state).await, 37 | Commands::Switch(options) => switch::handle(options, state).await, 38 | Commands::List(options) => list::handle(&options, &state), 39 | Commands::Docker(options) => docker::handle(&options, &mut state).await, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/auth/switch.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::format_users; 5 | use crate::config::EXEC_NAME; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Switch to a different user")] 10 | #[group(skip)] 11 | pub struct Options {} 12 | 13 | pub async fn handle(_options: Options, state: State) -> Result<()> { 14 | let users = state.auth.authorized.keys().collect::>(); 15 | 16 | ensure!( 17 | !users.is_empty(), 18 | "You are not logged in into any accounts, run `{} auth login` to login", 19 | EXEC_NAME 20 | ); 21 | 22 | let users_fmt = format_users(&users, false); 23 | 24 | let idx = dialoguer::Select::new() 25 | .with_prompt("Select a user") 26 | .items(&users_fmt) 27 | .default(0) 28 | .interact()?; 29 | 30 | let user_id = users.get(idx).unwrap().to_owned(); 31 | 32 | super::login::token(state.auth.authorized.clone().get(user_id).unwrap(), state).await 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/auth/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::commands::projects::types::Project; 4 | 5 | #[derive(Debug, Deserialize, Clone)] 6 | pub struct User { 7 | pub id: String, 8 | pub name: String, 9 | pub email: String, 10 | pub email_verified: bool, 11 | pub username: String, 12 | } 13 | 14 | #[derive(Debug, Deserialize, Clone, Default)] 15 | pub struct AuthorizedClient { 16 | pub id: String, 17 | pub name: String, 18 | pub leap_token: String, 19 | pub projects: Vec, 20 | pub email: String, 21 | pub email_verified: bool, 22 | } 23 | 24 | #[derive(Debug, Deserialize, Clone)] 25 | pub struct UserMe { 26 | pub leap_token: String, 27 | pub user: User, 28 | pub projects: Vec, 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/auth/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use tabwriter::TabWriter; 4 | 5 | pub fn format_users(users: &Vec<&String>, title: bool) -> Vec { 6 | let mut tw = TabWriter::new(vec![]); 7 | 8 | if title { 9 | writeln!(&mut tw, "ID").unwrap(); 10 | } 11 | 12 | for user in users { 13 | writeln!(&mut tw, "{user}").unwrap(); 14 | } 15 | 16 | String::from_utf8(tw.into_inner().unwrap()) 17 | .unwrap() 18 | .lines() 19 | .map(std::string::ToString::to_string) 20 | .collect() 21 | } 22 | -------------------------------------------------------------------------------- /src/commands/channels/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | use serde_json::Value; 4 | 5 | use super::types::ChannelType; 6 | use crate::commands::channels::utils::create_channel; 7 | use crate::state::State; 8 | use crate::utils::validate_json_non_null; 9 | 10 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 11 | #[clap(about = "Create a new Channel")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(short = 'i', long = "id", help = "Custom ID for the channel")] 15 | custom_id: Option, 16 | 17 | #[clap(short = 't', long = "type", help = "Type of the channel")] 18 | channel_type: Option, 19 | 20 | #[clap(short, long, help = "Initial state of the channel", value_parser = validate_json_non_null )] 21 | state: Option, 22 | } 23 | 24 | pub async fn handle(options: Options, state: State) -> Result<()> { 25 | let project_id = state.ctx.current_project_error()?.id; 26 | 27 | let (type_, id, init_state) = if Options::default() == options { 28 | let type_ = dialoguer::Select::new() 29 | .with_prompt("Select a channel type") 30 | .items(&ChannelType::variants()) 31 | .default(0) 32 | .interact()?; 33 | 34 | let type_ = ChannelType::variants()[type_].clone(); 35 | 36 | let id = if dialoguer::Confirm::new() 37 | .with_prompt("Do you want to specify a custom Channel ID?") 38 | .default(false) 39 | .interact()? 40 | { 41 | Some( 42 | dialoguer::Input::::new() 43 | .with_prompt("Enter a custom ID") 44 | .interact()?, 45 | ) 46 | } else { 47 | None 48 | }; 49 | 50 | let state = dialoguer::Input::new() 51 | .with_prompt("Enter the initial state of the channel") 52 | .default("{}".to_string()) 53 | .validate_with(|s: &String| -> Result<(), String> { 54 | validate_json_non_null(s) 55 | .map(|_| ()) 56 | .map_err(|e| e.to_string()) 57 | }) 58 | .interact()?; 59 | 60 | let state = serde_json::from_str(&state)?; 61 | 62 | (type_, id, state) 63 | } else { 64 | ( 65 | options.channel_type.clone().ok_or_else(|| { 66 | anyhow!( 67 | "The argument '--type ' requires a value but none was supplied" 68 | ) 69 | })?, 70 | options.custom_id.clone(), 71 | options 72 | .state 73 | .clone() 74 | .unwrap_or_else(|| Value::Object(serde_json::Map::new())), 75 | ) 76 | }; 77 | 78 | let channel = 79 | create_channel(&state.http, &project_id, &type_, &init_state, id.as_deref()).await?; 80 | 81 | log::info!("Created Channel `{}`", channel.id); 82 | 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/channels/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::delete_channel; 5 | use crate::commands::channels::utils::{format_channels, get_all_channels}; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Delete Channels")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(help = "IDs of the Channels")] 13 | channels: Vec, 14 | 15 | #[clap(short, long, help = "Skip confirmation")] 16 | force: bool, 17 | } 18 | 19 | pub async fn handle(options: Options, state: State) -> Result<()> { 20 | let project_id = state.ctx.current_project_error()?.id; 21 | 22 | let channels = if !options.channels.is_empty() { 23 | options.channels 24 | } else { 25 | let channels = get_all_channels(&state.http, &project_id).await?; 26 | ensure!(!channels.is_empty(), "No Channels found"); 27 | let channels_fmt = format_channels(&channels, false); 28 | 29 | let idxs = dialoguer::MultiSelect::new() 30 | .with_prompt("Select a Channel") 31 | .items(&channels_fmt) 32 | .interact()?; 33 | 34 | channels 35 | .iter() 36 | .enumerate() 37 | .filter(|(i, _)| idxs.contains(i)) 38 | .map(|(_, c)| c.id.clone()) 39 | .collect() 40 | }; 41 | 42 | if !options.force 43 | && !dialoguer::Confirm::new() 44 | .with_prompt(format!( 45 | "Are you sure you want to delete {} Channels?", 46 | channels.len() 47 | )) 48 | .default(false) 49 | .interact_opt()? 50 | .unwrap_or(false) 51 | { 52 | bail!("Aborted"); 53 | } 54 | 55 | let mut delete_count = 0; 56 | 57 | for channel in &channels { 58 | log::debug!("{channel:?}"); 59 | 60 | log::info!("Deleting Channel `{}`", channel); 61 | 62 | if let Err(err) = delete_channel(&state.http, &project_id, channel).await { 63 | log::error!("Failed to delete Channel `{}`: {}", channel, err); 64 | } else { 65 | delete_count += 1; 66 | } 67 | } 68 | 69 | log::info!("Deleted {delete_count}/{} Channels", channels.len()); 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/channels/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_channels, get_all_channels}; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 8 | #[clap(about = "List all Channel")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "Only print the IDs of the Channels")] 12 | pub quiet: bool, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let project_id = state.ctx.current_project_error()?.id; 17 | let channels = get_all_channels(&state.http, &project_id).await?; 18 | 19 | if options.quiet { 20 | let ids = channels 21 | .iter() 22 | .map(|d| d.id.as_str()) 23 | .collect::>() 24 | .join(" "); 25 | 26 | println!("{ids}"); 27 | } else { 28 | let channels_fmt = format_channels(&channels, true); 29 | 30 | println!("{}", channels_fmt.join("\n")); 31 | } 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /src/commands/channels/message.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::types::EventOptions; 5 | use super::utils::{format_channels, get_all_channels, message_channel}; 6 | use crate::commands::channels::utils::get_json_input; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 10 | #[clap(about = "Send a message to a Channel")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(short, long, help = "The ID of the Channel to send the message to")] 14 | channel: Option, 15 | 16 | #[clap(flatten)] 17 | event: EventOptions, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let project_id = state.ctx.current_project_error()?.id; 22 | 23 | let channel_id = if let Some(channel) = options.channel { 24 | channel 25 | } else { 26 | let channels = get_all_channels(&state.http, &project_id).await?; 27 | ensure!( 28 | !channels.is_empty(), 29 | "No Channels found in the current Project" 30 | ); 31 | let channels_fmt = format_channels(&channels, false); 32 | 33 | let idx = dialoguer::Select::new() 34 | .with_prompt("Select a Channel") 35 | .items(&channels_fmt) 36 | .default(0) 37 | .interact()?; 38 | 39 | channels.get(idx).unwrap().id.clone() 40 | }; 41 | 42 | let (event_name, event_data) = if options.event != EventOptions::default() { 43 | ( 44 | options.event.name.ok_or_else(|| { 45 | anyhow!("The argument '--event ' requires a value but none was supplied") 46 | })?, 47 | options 48 | .event 49 | .data 50 | .map(|d| serde_json::from_str(&d).unwrap()), 51 | ) 52 | } else { 53 | let event_name = dialoguer::Input::::new() 54 | .with_prompt("Enter the event to send to the Channel") 55 | .interact_text()?; 56 | 57 | let event_data = if dialoguer::Confirm::new() 58 | .with_prompt("Do you want to specify event data?") 59 | .default(false) 60 | .interact()? 61 | { 62 | Some(get_json_input()?) 63 | } else { 64 | None 65 | }; 66 | 67 | log::debug!("Event: {} Data: {:?}", event_name, event_data); 68 | 69 | (event_name, event_data) 70 | }; 71 | 72 | message_channel( 73 | &state.http, 74 | &project_id, 75 | &channel_id, 76 | &event_name, 77 | event_data, 78 | ) 79 | .await?; 80 | 81 | log::info!("Message sent to Channel `{}`", channel_id); 82 | 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/channels/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod list; 4 | mod message; 5 | mod subscribe; 6 | mod tokens; 7 | mod types; 8 | mod utils; 9 | 10 | use anyhow::Result; 11 | use clap::Parser; 12 | 13 | use crate::state::State; 14 | 15 | #[derive(Debug, Parser)] 16 | pub enum Commands { 17 | #[clap(name = "new", alias = "create")] 18 | Create(create::Options), 19 | #[clap(name = "ls", alias = "list")] 20 | List(list::Options), 21 | #[clap(name = "rm", alias = "delete")] 22 | Delete(delete::Options), 23 | #[clap(alias = "token")] 24 | Tokens(tokens::Options), 25 | #[clap(alias = "send", alias = "msg")] 26 | Message(message::Options), 27 | #[clap(alias = "sub", alias = "ts")] 28 | Subscribe(subscribe::Options), 29 | } 30 | 31 | #[derive(Debug, Parser)] 32 | #[clap(about = "Interact with Channels")] 33 | #[group(skip)] 34 | pub struct Options { 35 | #[clap(subcommand)] 36 | pub commands: Commands, 37 | } 38 | 39 | pub async fn handle(options: Options, state: State) -> Result<()> { 40 | match options.commands { 41 | Commands::Create(options) => create::handle(options, state).await, 42 | Commands::List(options) => list::handle(options, state).await, 43 | Commands::Delete(options) => delete::handle(options, state).await, 44 | Commands::Tokens(options) => tokens::handle(options, state).await, 45 | Commands::Message(options) => message::handle(options, state).await, 46 | Commands::Subscribe(options) => subscribe::handle(options, state).await, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/channels/subscribe.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_channels, get_all_channels, subscribe_to_channel}; 5 | use crate::commands::channels::tokens::utils::{format_tokens, get_all_tokens}; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 9 | #[clap(about = "Subscribe a Leap Token to a Channel")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(short, long, help = "The ID of the Channel to subscribe to")] 13 | channel: Option, 14 | #[clap( 15 | short, 16 | long, 17 | help = "The ID of the Leap Token to subscribe to the Channel" 18 | )] 19 | token: Option, 20 | } 21 | 22 | pub async fn handle(options: Options, state: State) -> Result<()> { 23 | let project_id = state.ctx.current_project_error()?.id; 24 | 25 | let channel_id = if let Some(channel_id) = options.channel { 26 | channel_id 27 | } else { 28 | let channels = get_all_channels(&state.http, &project_id).await?; 29 | ensure!( 30 | !channels.is_empty(), 31 | "No Channels found in Project '{}'", 32 | project_id 33 | ); 34 | let channels_fmt = format_channels(&channels, false); 35 | 36 | let idx = dialoguer::Select::new() 37 | .with_prompt("Select a Channel") 38 | .items(&channels_fmt) 39 | .default(0) 40 | .interact()?; 41 | 42 | channels[idx].id.clone() 43 | }; 44 | 45 | let token_id = if let Some(token_id) = options.token { 46 | token_id 47 | } else { 48 | let tokens = get_all_tokens(&state.http, &project_id).await?; 49 | ensure!( 50 | !tokens.is_empty(), 51 | "No Leap Tokens found in Project '{}'", 52 | project_id 53 | ); 54 | let tokens_fmt = format_tokens(&tokens, false); 55 | 56 | let idx = dialoguer::Select::new() 57 | .with_prompt("Select a Leap Token") 58 | .items(&tokens_fmt) 59 | .default(0) 60 | .interact()?; 61 | 62 | tokens[idx].id.clone() 63 | }; 64 | 65 | subscribe_to_channel(&state.http, &project_id, &channel_id, &token_id).await?; 66 | 67 | log::info!( 68 | "Subscribed Token '{}' to Channel '{}'", 69 | token_id, 70 | channel_id 71 | ); 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/channels/tokens/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use serde_json::Value; 4 | 5 | use super::utils::create_token; 6 | use crate::commands::channels::tokens::utils::parse_expiration; 7 | use crate::state::State; 8 | use crate::utils::validate_json; 9 | 10 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 11 | #[clap(about = "Create a new Leap Token")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap( 15 | short, 16 | long, 17 | help = "Expiration date of the token, can be a date (DD/MM/YYYY, DD-MM-YYYY) or a duration (60s, 1d, 30d, 1y)", 18 | value_parser = parse_expiration 19 | )] 20 | expiration: Option, 21 | 22 | #[clap( 23 | short, 24 | long, 25 | help = "Initial state of the token, can be any JSON value", 26 | value_parser = validate_json 27 | )] 28 | state: Option, 29 | } 30 | 31 | pub async fn handle(options: Options, state: State) -> Result<()> { 32 | let project_id = state.ctx.current_project_error()?.id; 33 | 34 | let (token_state, expires_at) = if options != Options::default() { 35 | ( 36 | options.state, 37 | options.expiration.map(|ex| parse_expiration(&ex).unwrap()), 38 | ) 39 | } else { 40 | let token_state = dialoguer::Input::::new() 41 | .with_prompt("State") 42 | .default("null".to_string()) 43 | .validate_with(|s: &String| validate_json(s).map(|_| ())) 44 | .interact_text()?; 45 | 46 | let expires_at = dialoguer::Input::::new() 47 | .with_prompt("Expiration date") 48 | .default("0".to_string()) 49 | .validate_with(|s: &String| parse_expiration(s).map(|_| ())) 50 | .interact_text()?; 51 | 52 | ( 53 | if token_state.to_lowercase() == "null" { 54 | None 55 | } else { 56 | Some(token_state) 57 | } 58 | .map(|s| s.parse().unwrap()), 59 | if expires_at.to_lowercase() == "0" { 60 | None 61 | } else { 62 | Some(parse_expiration(&expires_at).unwrap()) 63 | }, 64 | ) 65 | }; 66 | 67 | let token = create_token(&state.http, &project_id, expires_at.as_deref(), token_state).await?; 68 | 69 | log::info!("Created Token: `{}`", token.id); 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/channels/tokens/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::{delete_token, format_tokens, get_all_tokens}; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Delete Leap Tokens")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "IDs of the Leap Tokens")] 12 | tokens: Vec, 13 | 14 | #[clap(short, long, help = "Skip confirmation")] 15 | force: bool, 16 | } 17 | 18 | pub async fn handle(options: Options, state: State) -> Result<()> { 19 | let project_id = state.ctx.current_project_error()?.id; 20 | 21 | let tokens = if !options.tokens.is_empty() { 22 | options.tokens 23 | } else { 24 | let tokens = get_all_tokens(&state.http, &project_id).await?; 25 | ensure!(!tokens.is_empty(), "No Leap Tokens found"); 26 | let tokens_fmt = format_tokens(&tokens, false); 27 | 28 | let idxs = dialoguer::MultiSelect::new() 29 | .with_prompt("Select a Leap Token") 30 | .items(&tokens_fmt) 31 | .interact()?; 32 | 33 | tokens 34 | .iter() 35 | .enumerate() 36 | .filter(|(i, _)| idxs.contains(i)) 37 | .map(|(_, c)| c.id.clone()) 38 | .collect() 39 | }; 40 | 41 | if !options.force 42 | && !dialoguer::Confirm::new() 43 | .with_prompt(format!( 44 | "Are you sure you want to delete {} Leap Tokens?", 45 | tokens.len() 46 | )) 47 | .default(false) 48 | .interact_opt()? 49 | .unwrap_or(false) 50 | { 51 | bail!("Aborted"); 52 | } 53 | 54 | let mut delete_count = 0; 55 | 56 | for token in &tokens { 57 | log::debug!("{token:?}"); 58 | 59 | log::info!("Deleting Leap Token `{}`", token); 60 | delete_token(&state.http, &project_id, token).await?; 61 | delete_count += 1; 62 | } 63 | 64 | log::info!("Deleted {delete_count}/{} Leap Tokens", tokens.len()); 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/channels/tokens/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_tokens, get_all_tokens}; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 8 | #[clap(about = "List all Leap Tokens")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "Only print the IDs of the Tokens")] 12 | quiet: bool, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let project_id = state.ctx.current_project_error()?.id; 17 | let tokens = get_all_tokens(&state.http, &project_id).await?; 18 | 19 | if options.quiet { 20 | let ids = tokens 21 | .iter() 22 | .map(|d| d.id.as_str()) 23 | .collect::>() 24 | .join(" "); 25 | 26 | println!("{ids}"); 27 | } else { 28 | let channels_fmt = format_tokens(&tokens, true); 29 | 30 | println!("{}", channels_fmt.join("\n")); 31 | } 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /src/commands/channels/tokens/messages.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_tokens, get_all_tokens, message_token}; 5 | use crate::commands::channels::types::EventOptions; 6 | use crate::commands::channels::utils::get_json_input; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 10 | #[clap(about = "Send a message to a Leap Token")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(short, long, help = "The ID of the Token to send the message to")] 14 | token: Option, 15 | 16 | #[clap(flatten)] 17 | event: EventOptions, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let project_id = state.ctx.current_project_error()?.id; 22 | 23 | let token_id = if let Some(token) = options.token { 24 | token 25 | } else { 26 | let tokens = get_all_tokens(&state.http, &project_id).await?; 27 | ensure!( 28 | !tokens.is_empty(), 29 | "No Leap Tokens found in the current Project" 30 | ); 31 | let channels_fmt = format_tokens(&tokens, false); 32 | 33 | let idx = dialoguer::Select::new() 34 | .with_prompt("Select a Leap Token") 35 | .items(&channels_fmt) 36 | .default(0) 37 | .interact()?; 38 | 39 | tokens.get(idx).unwrap().id.clone() 40 | }; 41 | 42 | let (event_name, event_data) = if options.event != EventOptions::default() { 43 | ( 44 | options.event.name.ok_or_else(|| { 45 | anyhow!("The argument '--event ' requires a value but none was supplied") 46 | })?, 47 | options 48 | .event 49 | .data 50 | .map(|d| serde_json::from_str(&d).unwrap()), 51 | ) 52 | } else { 53 | let event_name = dialoguer::Input::::new() 54 | .with_prompt("Enter the event name to send") 55 | .interact_text()?; 56 | 57 | let event_data = if dialoguer::Confirm::new() 58 | .with_prompt("Do you want to specify event data?") 59 | .default(false) 60 | .interact()? 61 | { 62 | Some(get_json_input()?) 63 | } else { 64 | None 65 | }; 66 | 67 | log::debug!("Event: {} Data: {:?}", event_name, event_data); 68 | 69 | (event_name, event_data) 70 | }; 71 | 72 | message_token(&state.http, &project_id, &token_id, &event_name, event_data).await?; 73 | 74 | log::info!("Message sent to Leap Token `{}`", token_id); 75 | 76 | Ok(()) 77 | } 78 | -------------------------------------------------------------------------------- /src/commands/channels/tokens/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod list; 4 | mod messages; 5 | mod types; 6 | pub(super) mod utils; 7 | 8 | use anyhow::Result; 9 | use clap::Parser; 10 | 11 | use crate::state::State; 12 | 13 | #[derive(Debug, Parser)] 14 | pub enum Commands { 15 | #[clap(name = "new", alias = "create")] 16 | Create(create::Options), 17 | #[clap(name = "ls", alias = "list")] 18 | List(list::Options), 19 | #[clap(name = "rm", alias = "delete")] 20 | Delete(delete::Options), 21 | #[clap(alias = "send", alias = "msg")] 22 | Messages(messages::Options), 23 | } 24 | 25 | #[derive(Debug, Parser)] 26 | #[clap(about = "Interact with Channel Tokens")] 27 | #[group(skip)] 28 | pub struct Options { 29 | #[clap(subcommand)] 30 | pub commands: Commands, 31 | } 32 | 33 | pub async fn handle(options: Options, state: State) -> Result<()> { 34 | match options.commands { 35 | Commands::Create(options) => create::handle(options, state).await, 36 | Commands::List(options) => list::handle(options, state).await, 37 | Commands::Delete(options) => delete::handle(options, state).await, 38 | Commands::Messages(options) => messages::handle(options, state).await, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/channels/tokens/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::Value; 3 | 4 | #[derive(Debug, Deserialize)] 5 | pub struct LeapToken { 6 | pub id: String, 7 | pub created_at: String, 8 | pub state: Option, 9 | pub expires_at: Option, 10 | } 11 | 12 | #[derive(Debug, Serialize)] 13 | pub struct CreateLeapToken { 14 | pub expires_at: Option, 15 | pub state: Option, 16 | } 17 | 18 | #[derive(Debug, Deserialize)] 19 | pub struct SingleLeapToken { 20 | pub token: LeapToken, 21 | } 22 | 23 | #[derive(Debug, Deserialize)] 24 | pub struct MultipleLeapToken { 25 | pub tokens: Vec, 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/channels/types.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::str::FromStr; 3 | 4 | use anyhow::{anyhow, Result}; 5 | use clap::Parser; 6 | use serde::{Deserialize, Serialize}; 7 | use serde_json::Value; 8 | 9 | use crate::utils::validate_json; 10 | 11 | #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] 12 | #[serde(rename_all = "lowercase")] 13 | pub enum ChannelType { 14 | Public, 15 | Private, 16 | Unprotected, 17 | } 18 | 19 | impl FromStr for ChannelType { 20 | type Err = anyhow::Error; 21 | 22 | fn from_str(s: &str) -> Result { 23 | serde_json::from_str(&format!("\"{}\"", s.to_lowercase())).map_err(|e| anyhow!(e)) 24 | } 25 | } 26 | 27 | impl Display for ChannelType { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | write!( 30 | f, 31 | "{}", 32 | serde_json::to_string(self).unwrap().replace('"', "") 33 | ) 34 | } 35 | } 36 | 37 | impl ChannelType { 38 | pub fn variants() -> Vec { 39 | vec![Self::Public, Self::Private, Self::Unprotected] 40 | } 41 | } 42 | 43 | #[derive(Debug, Deserialize)] 44 | pub struct Channel { 45 | pub id: String, 46 | #[serde(rename = "type")] 47 | pub type_: ChannelType, 48 | pub created_at: String, 49 | // state is user specified 50 | pub state: Value, 51 | } 52 | 53 | #[derive(Debug, Serialize)] 54 | pub struct CreateChannel { 55 | #[serde(rename = "type")] 56 | pub type_: ChannelType, 57 | pub state: Value, 58 | } 59 | 60 | #[derive(Debug, Deserialize)] 61 | pub struct SingleChannel { 62 | pub channel: Channel, 63 | } 64 | 65 | #[derive(Debug, Deserialize)] 66 | pub struct PaginatedChannels { 67 | pub channels: Vec, 68 | pub page_size: u64, 69 | pub total_count: u64, 70 | } 71 | 72 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 73 | pub struct EventOptions { 74 | #[clap(short = 'e', long = "event", help = "Event name to send")] 75 | pub name: Option, 76 | #[clap(short = 'd', long = "data", help = "Event data to send", value_parser = validate_json)] 77 | pub data: Option, 78 | } 79 | 80 | pub type EventData = serde_json::Map; 81 | 82 | #[derive(Debug, Serialize)] 83 | pub struct MessageEvent { 84 | #[serde(rename = "e")] 85 | pub event: String, 86 | #[serde(rename = "d")] 87 | pub data: EventData, 88 | } 89 | -------------------------------------------------------------------------------- /src/commands/completions/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use clap::{CommandFactory, Parser}; 4 | use clap_complete::{generate, Shell as CompletionShell}; 5 | 6 | use crate::config::EXEC_NAME; 7 | use crate::state::State; 8 | use crate::CLI; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Generate completion scripts for the specified shell")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(help = "The shell to print the completion script for")] 15 | shell: CompletionShell, 16 | } 17 | 18 | pub fn handle(options: Options, _state: State) { 19 | generate( 20 | options.shell, 21 | &mut CLI::command(), 22 | EXEC_NAME, 23 | &mut io::stdout().lock(), 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/containers/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::containers::utils::create_containers; 5 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Create containers for a deployment")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(short, long, help = "ID of the deployment")] 13 | deployment: Option, 14 | 15 | #[clap(help = "Number of containers to create")] 16 | count: Option, 17 | } 18 | 19 | pub async fn handle(options: Options, state: State) -> Result<()> { 20 | let deployment_id = match options.deployment { 21 | Some(id) => id, 22 | 23 | None => { 24 | let (deployments_fmt, deployments, validator) = 25 | fetch_grouped_deployments(&state, false, true).await?; 26 | 27 | let idx = loop { 28 | let idx = dialoguer::Select::new() 29 | .with_prompt("Select a deployment") 30 | .items(&deployments_fmt) 31 | .default(0) 32 | .interact()?; 33 | 34 | if let Ok(idx) = validator(idx) { 35 | break idx; 36 | } 37 | 38 | console::Term::stderr().clear_last_lines(1)? 39 | }; 40 | 41 | deployments[idx].id.clone() 42 | } 43 | }; 44 | 45 | let count = match options.count { 46 | Some(count) => count, 47 | None => dialoguer::Input::::new() 48 | .with_prompt("Number of containers to create") 49 | .interact()?, 50 | }; 51 | 52 | ensure!(count > 0, "Count must be greater than 0"); 53 | 54 | create_containers(&state.http, &deployment_id, count).await?; 55 | 56 | log::info!("Created {} containers", count); 57 | 58 | Ok(()) 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/containers/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::delete_container; 5 | use crate::commands::containers::utils::{format_containers, get_all_containers}; 6 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Delete containers")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "IDs of the containers")] 14 | containers: Vec, 15 | 16 | #[clap(short, long, help = "Skip confirmation")] 17 | force: bool, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let containers = if !options.containers.is_empty() { 22 | options.containers 23 | } else { 24 | let (deployments_fmt, deployments, validator) = 25 | fetch_grouped_deployments(&state, false, true).await?; 26 | 27 | let idx = loop { 28 | let idx = dialoguer::Select::new() 29 | .with_prompt("Select a deployment") 30 | .items(&deployments_fmt) 31 | .default(0) 32 | .interact()?; 33 | 34 | if let Ok(idx) = validator(idx) { 35 | break idx; 36 | } 37 | 38 | console::Term::stderr().clear_last_lines(1)? 39 | }; 40 | 41 | let containers = get_all_containers(&state.http, &deployments[idx].id).await?; 42 | ensure!(!containers.is_empty(), "No containers found"); 43 | let containers_fmt = format_containers(&containers, false); 44 | 45 | let idxs = dialoguer::MultiSelect::new() 46 | .with_prompt("Select containers to delete") 47 | .items(&containers_fmt) 48 | .interact()?; 49 | 50 | containers 51 | .iter() 52 | .enumerate() 53 | .filter(|(i, _)| idxs.contains(i)) 54 | .map(|(_, c)| c.id.clone()) 55 | .collect() 56 | }; 57 | 58 | if !options.force 59 | && !dialoguer::Confirm::new() 60 | .with_prompt(format!( 61 | "Are you sure you want to delete {} containers?", 62 | containers.len() 63 | )) 64 | .interact_opt()? 65 | .unwrap_or(false) 66 | { 67 | bail!("Aborted"); 68 | } 69 | 70 | let mut delete_count = 0; 71 | 72 | for container in &containers { 73 | log::info!("Deleting container `{}`", container); 74 | 75 | if let Err(err) = delete_container(&state.http, container, false).await { 76 | log::error!("Failed to delete container `{}`: {}", container, err); 77 | } else { 78 | delete_count += 1; 79 | } 80 | } 81 | 82 | log::info!("Deleted {delete_count}/{} containers", containers.len()); 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/containers/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::commands::containers::utils::{format_containers, get_all_containers}; 5 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 6 | use crate::commands::ignite::utils::get_deployment; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "List all containers")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the deployment")] 14 | pub deployment: Option, 15 | 16 | #[clap(short, long, help = "Only print the IDs of the deployments")] 17 | pub quiet: bool, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let deployment = match options.deployment { 22 | Some(id) => get_deployment(&state.http, &id).await?, 23 | 24 | None => { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | deployments[idx].clone() 43 | } 44 | }; 45 | 46 | let containers = get_all_containers(&state.http, &deployment.id).await?; 47 | 48 | if options.quiet { 49 | let ids = containers 50 | .iter() 51 | .map(|d| d.id.as_str()) 52 | .collect::>() 53 | .join(" "); 54 | 55 | println!("{ids}"); 56 | } else { 57 | let containers_fmt = format_containers(&containers, true); 58 | 59 | println!("{}", containers_fmt.join("\n")); 60 | } 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/containers/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod inspect; 4 | mod list; 5 | mod logs; 6 | pub mod metrics; 7 | mod recreate; 8 | pub mod types; 9 | pub mod utils; 10 | 11 | use anyhow::Result; 12 | use clap::{Parser, Subcommand}; 13 | 14 | use crate::state::State; 15 | 16 | #[derive(Debug, Subcommand)] 17 | pub enum Commands { 18 | #[clap(name = "new", alias = "create")] 19 | Create(create::Options), 20 | #[clap(name = "rm", alias = "delete")] 21 | Delete(delete::Options), 22 | #[clap(name = "recreate")] 23 | Recreate(recreate::Options), 24 | #[clap(name = "ls", alias = "list")] 25 | List(list::Options), 26 | #[clap(alias = "info")] 27 | Inspect(inspect::Options), 28 | #[clap(alias = "stats")] 29 | Metrics(metrics::Options), 30 | 31 | #[clap(name = "logs", alias = "log")] 32 | Log(logs::Options), 33 | } 34 | 35 | #[derive(Debug, Parser)] 36 | #[clap(about = "Interact with Ignite containers")] 37 | #[group(skip)] 38 | pub struct Options { 39 | #[clap(subcommand)] 40 | pub commands: Commands, 41 | } 42 | 43 | pub async fn handle(options: Options, state: State) -> Result<()> { 44 | match options.commands { 45 | Commands::Create(options) => create::handle(options, state).await, 46 | Commands::Delete(options) => delete::handle(options, state).await, 47 | Commands::List(options) => list::handle(options, state).await, 48 | Commands::Log(options) => logs::handle(options, state).await, 49 | Commands::Recreate(options) => recreate::handle(options, state).await, 50 | Commands::Inspect(options) => inspect::handle(options, state).await, 51 | Commands::Metrics(options) => metrics::handle(options, state).await, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/commands/containers/recreate.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::delete_container; 5 | use crate::commands::containers::types::Container; 6 | use crate::commands::containers::utils::{format_containers, get_all_containers}; 7 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 8 | use crate::state::State; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Recreate containers")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(help = "IDs of the containers")] 15 | containers: Vec, 16 | 17 | #[clap(short, long, help = "Skip confirmation")] 18 | force: bool, 19 | } 20 | 21 | pub async fn handle(options: Options, state: State) -> Result<()> { 22 | let containers = if !options.containers.is_empty() { 23 | options.containers 24 | } else { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | let containers = get_all_containers(&state.http, &deployments[idx].id).await?; 43 | ensure!(!containers.is_empty(), "No containers found"); 44 | let containers_fmt = format_containers(&containers, false); 45 | 46 | let idxs = dialoguer::MultiSelect::new() 47 | .with_prompt("Select containers to recreate") 48 | .items(&containers_fmt) 49 | .interact()?; 50 | 51 | containers 52 | .iter() 53 | .enumerate() 54 | .filter(|(i, _)| idxs.contains(i)) 55 | .map(|(_, c)| c.id.clone()) 56 | .collect() 57 | }; 58 | 59 | if !options.force 60 | && !dialoguer::Confirm::new() 61 | .with_prompt(format!( 62 | "Are you sure you want to recreate {} containers?", 63 | containers.len() 64 | )) 65 | .interact_opt()? 66 | .unwrap_or(false) 67 | { 68 | bail!("Aborted"); 69 | } 70 | 71 | let mut recreated_count = 0; 72 | 73 | for container in &containers { 74 | log::info!("Recreating container `{container}`"); 75 | 76 | match delete_container(&state.http, container, true).await { 77 | Ok(Some(Container { id, .. })) => { 78 | log::info!("Recreated container `{container}`, new ID: `{id}`"); 79 | recreated_count += 1; 80 | } 81 | Ok(None) => log::error!("Failed to recreate container `{container}`"), 82 | Err(err) => log::error!("Failed to recreate container `{container}`: {err}"), 83 | } 84 | } 85 | 86 | log::info!( 87 | "Recreated {recreated_count}/{} containers", 88 | containers.len() 89 | ); 90 | 91 | Ok(()) 92 | } 93 | -------------------------------------------------------------------------------- /src/commands/deploy/builder/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize)] 4 | pub struct Build { 5 | pub id: String, 6 | } 7 | 8 | #[derive(Debug, Deserialize)] 9 | pub struct SingleBuild { 10 | pub build: Build, 11 | } 12 | 13 | #[derive(Debug, Deserialize)] 14 | #[serde(tag = "e", content = "d", rename_all = "SCREAMING_SNAKE_CASE")] 15 | pub enum BuildEvents { 16 | BuildProgress(BuildProgress), 17 | BuildCancelled(BuildEvent), 18 | PushSuccess(BuildEvent), 19 | PushFailure(BuildEvent), 20 | BuildUpdate(BuildValidationWrapper), 21 | BuildCreate(BuildValidationWrapper), 22 | } 23 | 24 | #[derive(Debug, Deserialize)] 25 | pub struct BuildProgress { 26 | pub build_id: String, 27 | pub deployment_id: String, 28 | pub id: String, 29 | pub log: String, 30 | pub sent_at: String, 31 | } 32 | 33 | #[derive(Debug, Deserialize)] 34 | pub struct BuildEvent { 35 | pub build_id: String, 36 | pub deployment_id: String, 37 | } 38 | 39 | #[derive(Debug, Deserialize)] 40 | pub struct BuildValidationWrapper { 41 | pub build: BuildValidationEvent, 42 | } 43 | 44 | #[derive(Debug, Deserialize)] 45 | pub struct BuildValidationEvent { 46 | pub deployment_id: String, 47 | pub id: String, 48 | pub state: BuildStatus, 49 | pub validation_failure: Option, 50 | } 51 | 52 | #[derive(Debug, Deserialize)] 53 | pub struct ValidationFailure { 54 | pub reason: String, 55 | pub help_link: String, 56 | } 57 | 58 | #[derive(Debug, Deserialize)] 59 | #[serde(rename_all = "snake_case")] 60 | pub enum BuildStatus { 61 | Pending, 62 | Validating, 63 | ValidationFailed, 64 | } 65 | -------------------------------------------------------------------------------- /src/commands/deploy/local/types.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::Deserialize; 4 | 5 | #[derive(Debug, Deserialize, Default)] 6 | pub struct DockerAuthStore { 7 | pub auths: HashMap, 8 | } 9 | 10 | #[derive(Debug, Deserialize, Default)] 11 | pub struct DockerAuth { 12 | // probably base64 but doesnt matter 13 | pub auth: String, 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/deploy/local/util.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::vec; 3 | 4 | use anyhow::{anyhow, bail, ensure, Result}; 5 | use tokio::fs; 6 | 7 | use crate::commands::update::types::GithubRelease; 8 | use crate::commands::update::util::{download, execute_commands, swap_exe_command, unpack}; 9 | use crate::config::ARCH; 10 | use crate::state::http::HttpClient; 11 | 12 | const RELEASE_NIXPACKS_URL: &str = "https://api.github.com/repos/hopinc/nixpacks/releases"; 13 | const BASE_NIXPACKS_URL: &str = "https://github.com/hopinc/nixpacks/releases/download"; 14 | 15 | pub async fn install_nixpacks(path: &PathBuf) -> Result<()> { 16 | log::debug!("Install nixpacks to {path:?}"); 17 | 18 | let http = HttpClient::new(None, None); 19 | 20 | let response = http 21 | .client 22 | .get(RELEASE_NIXPACKS_URL) 23 | .send() 24 | .await 25 | .map_err(|_| anyhow!("Failed to get latest release"))?; 26 | 27 | ensure!( 28 | response.status().is_success(), 29 | "Failed to get latest release from Github: {}", 30 | response.status() 31 | ); 32 | 33 | let data = response 34 | .json::>() 35 | .await 36 | .map_err(|_| anyhow!("Failed to parse Github release"))?; 37 | 38 | let version = &data.first().unwrap().tag_name; 39 | 40 | let platform = get_nixpacks_platform()?; 41 | 42 | let packed = download( 43 | &http, 44 | BASE_NIXPACKS_URL, 45 | version, 46 | &format!("nixpacks-{version}-{ARCH}-{platform}"), 47 | ) 48 | .await?; 49 | 50 | let unpacked = unpack(&packed, "nixpacks").await?; 51 | 52 | fs::remove_file(&packed).await.ok(); 53 | 54 | let mut elevated = vec![]; 55 | let mut non_elevated = vec![]; 56 | 57 | let parent = path.parent().unwrap().to_path_buf(); 58 | 59 | if fs::create_dir_all(&parent).await.is_err() { 60 | elevated.push(format!("mkdir -p {}", parent.display()).into()); 61 | } 62 | 63 | swap_exe_command(&mut non_elevated, &mut elevated, path.clone(), unpacked).await; 64 | execute_commands(&non_elevated, &elevated).await?; 65 | 66 | Ok(()) 67 | } 68 | 69 | fn get_nixpacks_platform() -> Result<&'static str> { 70 | match sys_info::os_type()?.to_lowercase().as_str() { 71 | "linux" => Ok("unknown-linux-musl"), 72 | "darwin" => Ok("apple-darwin"), 73 | "windows" => Ok("pc-windows-msvc"), 74 | _ => bail!("Unsupported platform"), 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/domains/attach.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::util::attach_domain; 5 | use crate::commands::gateways::util::{format_gateways, get_all_gateways}; 6 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Attach a domain to a Gateway")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the Gateway")] 14 | pub gateway: Option, 15 | 16 | #[clap(help = "Name of the domain")] 17 | pub domain: Option, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let gateway_id = match options.gateway { 22 | Some(id) => id, 23 | 24 | None => { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | let gateways = get_all_gateways(&state.http, &deployments[idx].id).await?; 43 | ensure!(!gateways.is_empty(), "No Gateways found"); 44 | let gateways_fmt = format_gateways(&gateways, false); 45 | 46 | let idx = dialoguer::Select::new() 47 | .with_prompt("Select a Gateway") 48 | .default(0) 49 | .items(&gateways_fmt) 50 | .interact()?; 51 | 52 | gateways[idx].id.clone() 53 | } 54 | }; 55 | 56 | let domain = match options.domain { 57 | Some(name) => name, 58 | 59 | None => dialoguer::Input::::new() 60 | .with_prompt("Enter the domain name") 61 | .interact()?, 62 | }; 63 | 64 | attach_domain(&state.http, &gateway_id, &domain).await?; 65 | 66 | log::info!("Attached domain `{}` to Gateway `{}`", domain, gateway_id); 67 | log::info!("Please create a non-proxied DNS record pointing to the following"); 68 | println!("\tCNAME {domain} -> border.hop.io"); 69 | 70 | Ok(()) 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/domains/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::util::{delete_domain, format_domains, get_all_domains}; 5 | use crate::commands::gateways::util::{format_gateways, get_all_gateways}; 6 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Detach a domain from a Gateway")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the domain")] 14 | pub domain: Option, 15 | } 16 | 17 | pub async fn handle(options: Options, state: State) -> Result<()> { 18 | let domain_id = match options.domain { 19 | Some(id) => id, 20 | 21 | None => { 22 | let (deployments_fmt, deployments, validator) = 23 | fetch_grouped_deployments(&state, false, true).await?; 24 | 25 | let idx = loop { 26 | let idx = dialoguer::Select::new() 27 | .with_prompt("Select a deployment") 28 | .items(&deployments_fmt) 29 | .default(0) 30 | .interact()?; 31 | 32 | if let Ok(idx) = validator(idx) { 33 | break idx; 34 | } 35 | 36 | console::Term::stderr().clear_last_lines(1)? 37 | }; 38 | 39 | let gateways = get_all_gateways(&state.http, &deployments[idx].id).await?; 40 | ensure!(!gateways.is_empty(), "No Gateways found"); 41 | let gateways_fmt = format_gateways(&gateways, false); 42 | 43 | let idx = dialoguer::Select::new() 44 | .with_prompt("Select a Gateway") 45 | .default(0) 46 | .items(&gateways_fmt) 47 | .interact()?; 48 | 49 | let domains = get_all_domains(&state.http, &gateways[idx].id).await?; 50 | let domains_fmt = format_domains(&domains, false); 51 | 52 | let idx = dialoguer::Select::new() 53 | .with_prompt("Select a domain") 54 | .default(0) 55 | .items(&domains_fmt) 56 | .interact()?; 57 | 58 | domains[idx].id.clone() 59 | } 60 | }; 61 | 62 | delete_domain(&state.http, &domain_id).await?; 63 | 64 | log::info!("Domain `{domain_id}` detached"); 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/domains/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::util::{format_domains, get_all_domains}; 5 | use crate::commands::gateways::util::{format_gateways, get_all_gateways}; 6 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "List all domains attached to a Gateway")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the Gateway")] 14 | pub gateway: Option, 15 | 16 | #[clap(short, long, help = "Only display domain IDs")] 17 | pub quiet: bool, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let gateway_id = match options.gateway { 22 | Some(id) => id, 23 | 24 | None => { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | let gateways = get_all_gateways(&state.http, &deployments[idx].id).await?; 43 | ensure!(!gateways.is_empty(), "No Gateways found"); 44 | let gateways_fmt = format_gateways(&gateways, false); 45 | 46 | let idx = dialoguer::Select::new() 47 | .with_prompt("Select a Gateway") 48 | .default(0) 49 | .items(&gateways_fmt) 50 | .interact()?; 51 | 52 | gateways[idx].id.clone() 53 | } 54 | }; 55 | 56 | let domains = get_all_domains(&state.http, &gateway_id).await?; 57 | 58 | if options.quiet { 59 | let ids = domains 60 | .iter() 61 | .map(|d| d.id.as_str()) 62 | .collect::>() 63 | .join(" "); 64 | 65 | println!("{ids}"); 66 | } else { 67 | let domains_fmt = format_domains(&domains, true); 68 | 69 | println!("{}", domains_fmt.join("\n")); 70 | } 71 | 72 | Ok(()) 73 | } 74 | -------------------------------------------------------------------------------- /src/commands/domains/mod.rs: -------------------------------------------------------------------------------- 1 | mod attach; 2 | mod delete; 3 | mod list; 4 | pub mod types; 5 | mod util; 6 | 7 | use anyhow::Result; 8 | use clap::{Parser, Subcommand}; 9 | 10 | use crate::state::State; 11 | 12 | #[derive(Debug, Subcommand)] 13 | pub enum Commands { 14 | #[clap(name = "attach", alias = "new", alias = "create")] 15 | Attach(attach::Options), 16 | #[clap(name = "ls", alias = "list")] 17 | List(list::Options), 18 | #[clap(name = "rm", alias = "del", alias = "delete", alias = "remove")] 19 | Delete(delete::Options), 20 | } 21 | 22 | #[derive(Debug, Parser)] 23 | #[clap(about = "Interact with domains")] 24 | #[group(skip)] 25 | pub struct Options { 26 | #[clap(subcommand)] 27 | pub commands: Commands, 28 | } 29 | 30 | pub async fn handle(options: Options, state: State) -> Result<()> { 31 | match options.commands { 32 | Commands::Attach(options) => attach::handle(options, state).await, 33 | Commands::List(options) => list::handle(options, state).await, 34 | Commands::Delete(options) => delete::handle(options, state).await, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/domains/types.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize)] 6 | pub struct AttachDomain<'a> { 7 | pub domain: &'a str, 8 | } 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct Domain { 12 | pub id: String, 13 | pub domain: String, 14 | pub created_at: String, 15 | pub state: DomainState, 16 | } 17 | 18 | #[derive(Debug, Deserialize, Serialize, Clone)] 19 | #[serde(rename_all = "snake_case")] 20 | pub enum DomainState { 21 | Pending, 22 | SslActive, 23 | ValidCname, 24 | } 25 | 26 | // this is only display for LIST 27 | impl Display for DomainState { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | write!( 30 | f, 31 | "{}", 32 | serde_json::to_string(self) 33 | .unwrap() 34 | .replace('"', "") 35 | .replace('_', " ") 36 | ) 37 | } 38 | } 39 | 40 | #[derive(Deserialize)] 41 | pub struct MultipleDomainsResponse { 42 | pub domains: Vec, 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/domains/util.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use serde_json::Value; 5 | use tabwriter::TabWriter; 6 | 7 | use super::types::{AttachDomain, Domain}; 8 | use crate::commands::gateways::types::SingleGateway; 9 | use crate::state::http::HttpClient; 10 | 11 | pub async fn attach_domain(http: &HttpClient, gateway_id: &str, domain: &str) -> Result<()> { 12 | http.request::( 13 | "POST", 14 | &format!("/ignite/gateways/{gateway_id}/domains"), 15 | Some(( 16 | serde_json::to_vec(&AttachDomain { domain }).unwrap().into(), 17 | "application/json", 18 | )), 19 | ) 20 | .await? 21 | .ok_or_else(|| anyhow::anyhow!("Error while parsing response"))?; 22 | 23 | Ok(()) 24 | } 25 | 26 | pub async fn get_all_domains(http: &HttpClient, gateway_id: &str) -> Result> { 27 | let response = http 28 | .request::("GET", &format!("/ignite/gateways/{gateway_id}"), None) 29 | .await? 30 | .ok_or_else(|| anyhow::anyhow!("Error while parsing response"))?; 31 | 32 | Ok(response.gateway.domains) 33 | } 34 | 35 | pub async fn delete_domain(http: &HttpClient, domain_id: &str) -> Result<()> { 36 | http.request::("DELETE", &format!("/ignite/domains/{domain_id}"), None) 37 | .await?; 38 | 39 | Ok(()) 40 | } 41 | 42 | pub fn format_domains(domains: &[Domain], title: bool) -> Vec { 43 | let mut tw = TabWriter::new(vec![]); 44 | 45 | if title { 46 | writeln!(tw, "ID\tDOMAIN\tSTATE\tCREATION").unwrap(); 47 | } 48 | 49 | for domain in domains { 50 | writeln!( 51 | tw, 52 | "{}\t{}\t{}\t{}", 53 | domain.id, domain.domain, domain.state, domain.created_at 54 | ) 55 | .unwrap(); 56 | } 57 | 58 | String::from_utf8(tw.into_inner().unwrap()) 59 | .unwrap() 60 | .lines() 61 | .map(std::string::ToString::to_string) 62 | .collect() 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/gateways/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::types::{GatewayProtocol, GatewayType}; 5 | use crate::commands::gateways::types::GatewayConfig; 6 | use crate::commands::gateways::util::{create_gateway, update_gateway_config}; 7 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 8 | use crate::state::State; 9 | use crate::utils::urlify; 10 | 11 | #[derive(Debug, Parser, Default, PartialEq, Eq)] 12 | pub struct GatewayOptions { 13 | #[clap(short = 'n', long = "name", help = "Name of the Gateway")] 14 | pub name: Option, 15 | 16 | #[clap(short = 't', long = "type", help = "Type of the Gateway")] 17 | pub type_: Option, 18 | 19 | #[clap(long = "protocol", help = "Protocol of the Gateway")] 20 | pub protocol: Option, 21 | 22 | #[clap(long = "target-port", help = "Port of the Gateway")] 23 | pub target_port: Option, 24 | 25 | #[clap(long = "internal-domain", help = "Internal domain of the Gateway")] 26 | pub internal_domain: Option, 27 | } 28 | 29 | #[derive(Debug, Parser)] 30 | #[clap(about = "Create a Gateway")] 31 | #[group(skip)] 32 | pub struct Options { 33 | #[clap(name = "deployment", help = "ID of the deployment")] 34 | pub deployment: Option, 35 | 36 | #[clap(flatten)] 37 | pub config: GatewayOptions, 38 | } 39 | 40 | pub async fn handle(options: Options, state: State) -> Result<()> { 41 | let deployment_id = match options.deployment { 42 | Some(deployment) => deployment, 43 | 44 | None => { 45 | let (deployments_fmt, deployments, validator) = 46 | fetch_grouped_deployments(&state, false, true).await?; 47 | 48 | let idx = loop { 49 | let idx = dialoguer::Select::new() 50 | .with_prompt("Select a deployment") 51 | .items(&deployments_fmt) 52 | .default(0) 53 | .interact()?; 54 | 55 | if let Ok(idx) = validator(idx) { 56 | break idx; 57 | } 58 | 59 | console::Term::stderr().clear_last_lines(1)? 60 | }; 61 | 62 | deployments[idx].id.clone() 63 | } 64 | }; 65 | 66 | let gateway_config = update_gateway_config( 67 | &options.config, 68 | options.config != GatewayOptions::default(), 69 | false, 70 | &GatewayConfig::default(), 71 | )?; 72 | 73 | let gateway = create_gateway(&state.http, &deployment_id, &gateway_config).await?; 74 | 75 | log::info!("Created Gateway `{}`", gateway.id); 76 | 77 | if gateway.type_ == GatewayType::External { 78 | log::info!( 79 | "You can now access your app at {}", 80 | urlify(&gateway.full_url()) 81 | ); 82 | } 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/gateways/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::gateways::util::{delete_gateway, format_gateways, get_all_gateways}; 5 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Delete gateways")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(name = "gateways", help = "IDs of the gateways")] 13 | gateways: Vec, 14 | 15 | #[clap(short = 'f', long = "force", help = "Skip confirmation")] 16 | force: bool, 17 | } 18 | 19 | pub async fn handle(options: Options, state: State) -> Result<()> { 20 | let gateways = if !options.gateways.is_empty() { 21 | options.gateways 22 | } else { 23 | let (deployments_fmt, deployments, validator) = 24 | fetch_grouped_deployments(&state, false, true).await?; 25 | 26 | let idx = loop { 27 | let idx = dialoguer::Select::new() 28 | .with_prompt("Select a deployment") 29 | .items(&deployments_fmt) 30 | .default(0) 31 | .interact()?; 32 | 33 | if let Ok(idx) = validator(idx) { 34 | break idx; 35 | } 36 | 37 | console::Term::stderr().clear_last_lines(1)? 38 | }; 39 | 40 | let gateways = get_all_gateways(&state.http, &deployments[idx].id).await?; 41 | let gateways_fmt = format_gateways(&gateways, false); 42 | 43 | let idxs = dialoguer::MultiSelect::new() 44 | .with_prompt("Select Gateways to delete") 45 | .items(&gateways_fmt) 46 | .interact()?; 47 | 48 | gateways 49 | .iter() 50 | .enumerate() 51 | .filter(|(i, _)| idxs.contains(i)) 52 | .map(|(_, c)| c.id.clone()) 53 | .collect() 54 | }; 55 | 56 | if !options.force 57 | && !dialoguer::Confirm::new() 58 | .with_prompt(format!( 59 | "Are you sure you want to delete {} Gateways?", 60 | gateways.len() 61 | )) 62 | .interact_opt()? 63 | .unwrap_or(false) 64 | { 65 | bail!("Aborted"); 66 | } 67 | 68 | let mut delete_count = 0; 69 | 70 | for gateway in &gateways { 71 | log::info!("Deleting Gateway `{gateway}`"); 72 | 73 | if let Err(err) = delete_gateway(&state.http, gateway).await { 74 | log::error!("Failed to delete Gateway `{}`: {}", gateway, err); 75 | } else { 76 | delete_count += 1; 77 | } 78 | } 79 | 80 | log::info!("Deleted {delete_count}/{} Gateways", gateways.len()); 81 | 82 | Ok(()) 83 | } 84 | -------------------------------------------------------------------------------- /src/commands/gateways/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::commands::gateways::util::{format_gateways, get_all_gateways}; 5 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "List all Gateways")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(name = "deployment", help = "ID of the deployment")] 13 | pub deployment: Option, 14 | 15 | #[clap( 16 | short = 'q', 17 | long = "quiet", 18 | help = "Only print the IDs of the deployments" 19 | )] 20 | pub quiet: bool, 21 | } 22 | 23 | pub async fn handle(options: Options, state: State) -> Result<()> { 24 | let deployment_id = match options.deployment { 25 | Some(deployment) => deployment, 26 | 27 | None => { 28 | let (deployments_fmt, deployments, validator) = 29 | fetch_grouped_deployments(&state, false, true).await?; 30 | 31 | let idx = loop { 32 | let idx = dialoguer::Select::new() 33 | .with_prompt("Select a deployment") 34 | .items(&deployments_fmt) 35 | .default(0) 36 | .interact()?; 37 | 38 | if let Ok(idx) = validator(idx) { 39 | break idx; 40 | } 41 | 42 | console::Term::stderr().clear_last_lines(1)? 43 | }; 44 | 45 | deployments[idx].id.clone() 46 | } 47 | }; 48 | 49 | let gateways = get_all_gateways(&state.http, &deployment_id).await?; 50 | 51 | if options.quiet { 52 | let ids = gateways 53 | .iter() 54 | .map(|d| d.id.as_str()) 55 | .collect::>() 56 | .join(" "); 57 | 58 | println!("{ids}"); 59 | } else { 60 | let containers_fmt = format_gateways(&gateways, true); 61 | 62 | println!("{}", containers_fmt.join("\n")); 63 | } 64 | 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/gateways/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create; 2 | mod delete; 3 | mod list; 4 | pub mod types; 5 | mod update; 6 | pub mod util; 7 | 8 | use anyhow::Result; 9 | use clap::{Parser, Subcommand}; 10 | 11 | use crate::state::State; 12 | 13 | #[derive(Debug, Subcommand)] 14 | pub enum Commands { 15 | #[clap(name = "new", alias = "create")] 16 | Create(create::Options), 17 | #[clap(name = "rm", alias = "delete")] 18 | Delete(delete::Options), 19 | #[clap(name = "ls", alias = "list")] 20 | List(list::Options), 21 | Update(update::Options), 22 | Domains(super::domains::Options), 23 | } 24 | 25 | #[derive(Debug, Parser)] 26 | #[clap(name = "gateways", about = "Interact with Ignite Gateways")] 27 | #[group(skip)] 28 | pub struct Options { 29 | #[clap(subcommand)] 30 | pub commands: Commands, 31 | } 32 | 33 | pub async fn handle(options: Options, state: State) -> Result<()> { 34 | match options.commands { 35 | Commands::Create(options) => create::handle(options, state).await, 36 | Commands::Delete(options) => delete::handle(options, state).await, 37 | Commands::List(options) => list::handle(options, state).await, 38 | Commands::Update(options) => update::handle(options, state).await, 39 | Commands::Domains(options) => super::domains::handle(options, state).await, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/gateways/update.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::create::GatewayOptions; 5 | use crate::commands::gateways::types::GatewayConfig; 6 | use crate::commands::gateways::util::{ 7 | format_gateways, get_all_gateways, get_gateway, update_gateway, update_gateway_config, 8 | }; 9 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 10 | use crate::state::State; 11 | 12 | #[derive(Debug, Parser)] 13 | #[clap(about = "Update a Gateway")] 14 | #[group(skip)] 15 | pub struct Options { 16 | #[clap(name = "gateway", help = "ID of the Gateway")] 17 | pub gateway: Option, 18 | 19 | #[clap(flatten)] 20 | pub config: GatewayOptions, 21 | } 22 | 23 | pub async fn handle(options: Options, state: State) -> Result<()> { 24 | let gateway = match options.gateway { 25 | Some(gateway_id) => get_gateway(&state.http, &gateway_id).await?, 26 | 27 | None => { 28 | let (deployments_fmt, deployments, validator) = 29 | fetch_grouped_deployments(&state, false, true).await?; 30 | 31 | let idx = loop { 32 | let idx = dialoguer::Select::new() 33 | .with_prompt("Select a deployment") 34 | .items(&deployments_fmt) 35 | .default(0) 36 | .interact()?; 37 | 38 | if let Ok(idx) = validator(idx) { 39 | break idx; 40 | } 41 | 42 | console::Term::stderr().clear_last_lines(1)? 43 | }; 44 | 45 | let gateways = get_all_gateways(&state.http, &deployments[idx].id).await?; 46 | let gateways_fmt = format_gateways(&gateways, false); 47 | 48 | let idx = dialoguer::Select::new() 49 | .with_prompt("Select a Gateway to update") 50 | .default(0) 51 | .items(&gateways_fmt) 52 | .interact()?; 53 | 54 | gateways[idx].clone() 55 | } 56 | }; 57 | 58 | let gateway_config = update_gateway_config( 59 | &options.config, 60 | options.config != GatewayOptions::default(), 61 | true, 62 | &GatewayConfig::from_gateway(&gateway), 63 | )?; 64 | 65 | update_gateway(&state.http, &gateway.id, &gateway_config).await?; 66 | 67 | log::info!("Updated Gateway `{}`", gateway.id); 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/ignite/builds/cancel.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::{cancel_build, format_builds, get_all_builds}; 5 | use crate::commands::ignite::builds::types::BuildState; 6 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Cancel a running build")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the deployment")] 14 | pub build: Option, 15 | 16 | #[clap(short, long, help = "Skip confirmation")] 17 | pub force: bool, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let build_id = match options.build { 22 | Some(id) => id, 23 | 24 | None => { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | let builds = get_all_builds(&state.http, &deployments[idx].id) 43 | .await? 44 | .into_iter() 45 | .filter(|b| matches!(b.state, BuildState::Pending)) 46 | .collect::>(); 47 | ensure!(!builds.is_empty(), "No running builds found"); 48 | let builds_fmt = format_builds(&builds, false); 49 | 50 | let idx = dialoguer::Select::new() 51 | .with_prompt("Select a build") 52 | .items(&builds_fmt) 53 | .default(0) 54 | .interact()?; 55 | 56 | builds[idx].id.clone() 57 | } 58 | }; 59 | 60 | if !options.force 61 | && !dialoguer::Confirm::new() 62 | .with_prompt("Are you sure you want to cancel this build?") 63 | .interact_opt()? 64 | .unwrap_or(false) 65 | { 66 | bail!("Aborted by user"); 67 | } 68 | 69 | cancel_build(&state.http, &build_id).await?; 70 | 71 | log::info!("Build `{build_id}` cancelled"); 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/ignite/builds/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_builds, get_all_builds}; 5 | use crate::{commands::ignite::groups::utils::fetch_grouped_deployments, state::State}; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List all builds in a deployment")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "ID of the deployment")] 12 | pub deployment: Option, 13 | 14 | #[clap(short, long, help = "Only print the IDs of the builds")] 15 | pub quiet: bool, 16 | } 17 | 18 | pub async fn handle(options: Options, state: State) -> Result<()> { 19 | let deployment_id = match options.deployment { 20 | Some(id) => id, 21 | 22 | None => { 23 | let (deployments_fmt, deployments, validator) = 24 | fetch_grouped_deployments(&state, false, true).await?; 25 | 26 | let idx = loop { 27 | let idx = dialoguer::Select::new() 28 | .with_prompt("Select a deployment") 29 | .items(&deployments_fmt) 30 | .default(0) 31 | .interact()?; 32 | 33 | if let Ok(idx) = validator(idx) { 34 | break idx; 35 | } 36 | 37 | console::Term::stderr().clear_last_lines(1)? 38 | }; 39 | 40 | deployments[idx].id.clone() 41 | } 42 | }; 43 | 44 | let builds = get_all_builds(&state.http, &deployment_id).await?; 45 | 46 | if options.quiet { 47 | let ids = builds 48 | .iter() 49 | .map(|d| d.id.as_str()) 50 | .collect::>() 51 | .join(" "); 52 | 53 | println!("{ids}"); 54 | } else { 55 | let builds_fmt = format_builds(&builds, true); 56 | 57 | println!("{}", builds_fmt.join("\n")); 58 | } 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/ignite/builds/mod.rs: -------------------------------------------------------------------------------- 1 | mod cancel; 2 | mod list; 3 | pub mod types; 4 | pub mod utils; 5 | 6 | use anyhow::Result; 7 | use clap::{Parser, Subcommand}; 8 | 9 | use crate::state::State; 10 | 11 | #[derive(Debug, Subcommand)] 12 | pub enum Commands { 13 | #[clap(name = "ls", alias = "list")] 14 | List(list::Options), 15 | #[clap(alias = "stop")] 16 | Cancel(cancel::Options), 17 | } 18 | 19 | #[derive(Debug, Parser)] 20 | #[clap(about = "Interact with Ignite Builds")] 21 | #[group(skip)] 22 | pub struct Options { 23 | #[clap(subcommand)] 24 | pub commands: Commands, 25 | } 26 | 27 | pub async fn handle(options: Options, state: State) -> Result<()> { 28 | match options.commands { 29 | Commands::List(options) => list::handle(options, state).await, 30 | Commands::Cancel(options) => cancel::handle(options, state).await, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/ignite/builds/types.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use chrono::{DateTime, Utc}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Debug, Deserialize)] 7 | pub struct MultipleBuilds { 8 | pub builds: Vec, 9 | } 10 | 11 | #[derive(Debug, Deserialize, Serialize)] 12 | #[serde(rename_all = "lowercase")] 13 | pub enum BuildMethod { 14 | Cli, 15 | Github, 16 | } 17 | 18 | impl Display for BuildMethod { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | write!( 21 | f, 22 | "{}", 23 | serde_json::to_string(self).unwrap().replace('"', "") 24 | ) 25 | } 26 | } 27 | 28 | #[derive(Debug, Deserialize, Serialize)] 29 | #[serde(rename_all = "lowercase")] 30 | pub enum BuildState { 31 | Pending, 32 | Succeeded, 33 | Failed, 34 | Cancelled, 35 | } 36 | 37 | impl Display for BuildState { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | write!( 40 | f, 41 | "{}", 42 | serde_json::to_string(self).unwrap().replace('"', "") 43 | ) 44 | } 45 | } 46 | 47 | #[derive(Debug, Deserialize)] 48 | pub struct Build { 49 | pub id: String, 50 | pub deployment_id: String, 51 | pub method: BuildMethod, 52 | pub started_at: DateTime, 53 | pub state: BuildState, 54 | pub digest: Option, 55 | pub finished_at: Option>, 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/ignite/builds/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use ms::{__to_string__, ms}; 5 | use serde_json::Value; 6 | 7 | use super::types::{Build, MultipleBuilds}; 8 | use crate::state::http::HttpClient; 9 | use crate::utils::relative_time; 10 | 11 | pub async fn get_all_builds(http: &HttpClient, deployment_id: &str) -> Result> { 12 | let mut response = http 13 | .request::( 14 | "GET", 15 | &format!("/ignite/deployments/{deployment_id}/builds"), 16 | None, 17 | ) 18 | .await? 19 | .ok_or_else(|| anyhow::anyhow!("Could not parse response"))?; 20 | 21 | response 22 | .builds 23 | .sort_by_cached_key(|build| std::cmp::Reverse(build.started_at.timestamp())); 24 | 25 | Ok(response.builds) 26 | } 27 | 28 | pub async fn cancel_build(http: &HttpClient, build_id: &str) -> Result<()> { 29 | http.request::("POST", &format!("/ignite/builds/{build_id}/cancel"), None) 30 | .await?; 31 | 32 | Ok(()) 33 | } 34 | 35 | pub fn format_builds(builds: &[Build], title: bool) -> Vec { 36 | let mut tw = tabwriter::TabWriter::new(vec![]); 37 | 38 | if title { 39 | writeln!(&mut tw, "ID\tSTATUS\tDIGEST\tMETHOD\tSTARTED\tDURATION").unwrap(); 40 | } 41 | 42 | for build in builds { 43 | writeln!( 44 | &mut tw, 45 | "{}\t{}\t{}\t{}\t{}\t{}", 46 | build.id, 47 | build.state, 48 | build.digest.clone().unwrap_or_else(|| "-".to_string()), 49 | build.method, 50 | relative_time(build.started_at), 51 | build 52 | .finished_at 53 | .map(|t| ms!( 54 | (t - build.started_at).num_milliseconds().unsigned_abs(), 55 | true 56 | )) 57 | .unwrap_or_default(), 58 | ) 59 | .unwrap(); 60 | } 61 | 62 | String::from_utf8(tw.into_inner().unwrap()) 63 | .unwrap() 64 | .lines() 65 | .map(std::string::ToString::to_string) 66 | .collect() 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/ignite/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Parser; 3 | 4 | use crate::{ 5 | commands::ignite::{groups::utils::fetch_grouped_deployments, utils::delete_deployment}, 6 | state::State, 7 | }; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Delete a deployment")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the deployment to delete")] 14 | deployment: Option, 15 | 16 | #[clap(short, long, help = "Skip confirmation")] 17 | force: bool, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let deployment_id = match options.deployment { 22 | Some(id) => id, 23 | 24 | None => { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | deployments[idx].id.clone() 43 | } 44 | }; 45 | 46 | if !options.force 47 | && !dialoguer::Confirm::new() 48 | .with_prompt("Are you sure you want to delete the deployment?") 49 | .interact_opt()? 50 | .unwrap_or(false) 51 | { 52 | bail!("Aborted"); 53 | } 54 | 55 | delete_deployment(&state.http, &deployment_id).await?; 56 | 57 | log::info!("Deployment `{}` deleted", deployment_id); 58 | 59 | Ok(()) 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/ignite/from_compose/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use regex::Regex; 3 | 4 | use super::types::Service; 5 | 6 | // order services by their dependencies 7 | // unsure of the accuracy of this algorithm but its fine for now 8 | pub fn order_by_dependencies(services: &mut [(&String, &Service)]) { 9 | services.sort_by(|(a_name, a_service), (b_name, b_service)| { 10 | let a_depends_on = a_service.depends_on.clone(); 11 | let b_depends_on = b_service.depends_on.clone(); 12 | 13 | if a_depends_on.is_none() && b_depends_on.is_none() { 14 | return std::cmp::Ordering::Equal; 15 | } 16 | 17 | if a_depends_on.is_none() && b_depends_on.is_some() { 18 | return std::cmp::Ordering::Less; 19 | } 20 | 21 | if b_depends_on.is_none() { 22 | return std::cmp::Ordering::Greater; 23 | } 24 | 25 | let a_depends_on = a_depends_on.unwrap(); 26 | let b_depends_on = b_depends_on.unwrap(); 27 | 28 | if a_depends_on.contains(b_name) { 29 | return std::cmp::Ordering::Less; 30 | } 31 | 32 | if b_depends_on.contains(a_name) { 33 | return std::cmp::Ordering::Greater; 34 | } 35 | 36 | std::cmp::Ordering::Equal 37 | }); 38 | } 39 | 40 | const DURATION_UNITS: [&str; 5] = ["us", "ms", "s", "m", "h"]; 41 | 42 | pub fn get_seconds_from_docker_duration(duration: &str) -> Result { 43 | let validate = Regex::new(&format!(r"^((\d*)({}))+$", DURATION_UNITS.join("|")))?; 44 | 45 | if !validate.is_match(duration) { 46 | bail!("Invalid duration: {duration}"); 47 | } 48 | 49 | let regex = Regex::new(&format!(r"(\d+)({})", DURATION_UNITS.join("|")))?; 50 | 51 | let captures = regex.captures_iter(duration); 52 | 53 | let mut out: u64 = 0; 54 | 55 | for capture in captures { 56 | let value = capture.get(1).unwrap().as_str().parse::()?; 57 | let unit = capture.get(2).unwrap().as_str(); 58 | 59 | let multiplier = match unit { 60 | "us" => 1, 61 | "ms" => 1000, 62 | "s" => 1000 * 1000, 63 | "m" => 1000 * 1000 * 60, 64 | "h" => 1000 * 1000 * 60 * 60, 65 | _ => bail!("Invalid unit: {unit}",), 66 | }; 67 | 68 | out += value * multiplier; 69 | } 70 | 71 | Ok(out / 1000 / 1000) 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/ignite/get_env.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | 6 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 7 | use crate::commands::ignite::utils::get_deployment; 8 | use crate::commands::secrets::utils::get_secret_name; 9 | use crate::state::State; 10 | 11 | #[derive(Debug, Parser)] 12 | #[clap(about = "Get current deployments env values")] 13 | #[group(skip)] 14 | pub struct Options { 15 | #[clap(help = "ID of the deployment to get env values")] 16 | pub deployment: Option, 17 | } 18 | 19 | pub async fn handle(options: Options, state: State) -> Result<()> { 20 | let deployment = match options.deployment { 21 | Some(id) => get_deployment(&state.http, &id).await?, 22 | 23 | None => { 24 | let (deployments_fmt, deployments, validator) = 25 | fetch_grouped_deployments(&state, false, true).await?; 26 | 27 | let idx = loop { 28 | let idx = dialoguer::Select::new() 29 | .with_prompt("Select a deployment") 30 | .items(&deployments_fmt) 31 | .default(0) 32 | .interact()?; 33 | 34 | if let Ok(idx) = validator(idx) { 35 | break idx; 36 | } 37 | 38 | console::Term::stderr().clear_last_lines(1)? 39 | }; 40 | 41 | deployments[idx].clone() 42 | } 43 | }; 44 | 45 | let mut buff = vec![]; 46 | 47 | for (key, value) in deployment.config.env { 48 | let value = if let Some(secret_name) = get_secret_name(&value) { 49 | format!("{{{secret_name}}}") 50 | } else { 51 | value 52 | }; 53 | 54 | writeln!(buff, "{key}={value}")?; 55 | } 56 | 57 | print!("{}", String::from_utf8(buff)?); 58 | 59 | Ok(()) 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/ignite/groups/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Create a ne Ignite group")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "The name of the group")] 12 | pub name: Option, 13 | #[clap(short, long, help = "The deployments to add to the group")] 14 | pub deployments: Vec, 15 | } 16 | 17 | pub async fn handle(options: Options, state: State) -> Result<()> { 18 | let project = state.ctx.current_project_error()?; 19 | 20 | let name = if let Some(name) = options.name { 21 | name 22 | } else { 23 | dialoguer::Input::new() 24 | .with_prompt("Group Name") 25 | .interact_text()? 26 | }; 27 | 28 | let deployments = if !options.deployments.is_empty() { 29 | options.deployments 30 | } else { 31 | let (deployments_fmt, deployments, validator) = 32 | fetch_grouped_deployments(&state, false, true).await?; 33 | 34 | let idxs = loop { 35 | let idxs = dialoguer::MultiSelect::new() 36 | .with_prompt("Select deployments") 37 | .items(&deployments_fmt) 38 | .interact()?; 39 | 40 | if !idxs.is_empty() && idxs.iter().all(|idx| validator(*idx).is_ok()) { 41 | break idxs; 42 | } 43 | 44 | console::Term::stderr().clear_last_lines(1)? 45 | } 46 | .into_iter() 47 | .map(|idx| validator(idx).unwrap()) 48 | .collect::>(); 49 | 50 | idxs.into_iter() 51 | .map(|idx| deployments[idx].id.clone()) 52 | .collect() 53 | }; 54 | 55 | let group = state 56 | .hop 57 | .ignite 58 | .groups 59 | .create( 60 | &project.id, 61 | &name, 62 | &deployments.iter().map(|id| id.as_str()).collect::>(), 63 | ) 64 | .await?; 65 | 66 | log::info!("Group successfully created. ID: {}\n", group.id); 67 | 68 | Ok(()) 69 | } 70 | -------------------------------------------------------------------------------- /src/commands/ignite/groups/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use crate::state::State; 5 | 6 | use super::utils::format_groups; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Delete an Ignite group")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(help = "The ID of the group")] 13 | pub group: Option, 14 | } 15 | 16 | pub async fn handle(options: Options, state: State) -> Result<()> { 17 | let group = if let Some(group) = options.group { 18 | group 19 | } else { 20 | let project = state.ctx.current_project_error()?; 21 | 22 | let mut groups = state.hop.ignite.groups.get_all(&project.id).await?; 23 | 24 | ensure!(!groups.is_empty(), "No groups found"); 25 | 26 | groups.sort_unstable_by_key(|group| group.position); 27 | 28 | let dialoguer_groups = dialoguer::Select::new() 29 | .with_prompt("Select group") 30 | .items(&format_groups(&groups)?) 31 | .interact()?; 32 | 33 | groups[dialoguer_groups].id.clone() 34 | }; 35 | 36 | state.hop.ignite.groups.delete(&group).await?; 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/ignite/groups/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::commands::ignite::groups::utils::format_groups; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List all Ignite groups")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap( 12 | short = 'q', 13 | long = "quiet", 14 | help = "Only print the IDs of the deployments" 15 | )] 16 | pub quiet: bool, 17 | } 18 | 19 | pub async fn handle(options: Options, state: State) -> Result<()> { 20 | let project = state.ctx.current_project_error()?; 21 | 22 | let groups = state.hop.ignite.groups.get_all(&project.id).await?; 23 | 24 | if options.quiet { 25 | let ids = groups 26 | .iter() 27 | .map(|d| d.id.as_str()) 28 | .collect::>() 29 | .join(" "); 30 | 31 | println!("{ids}"); 32 | } else { 33 | let formated = format_groups(&groups)?; 34 | 35 | println!("{}", formated.join("\n")) 36 | } 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/ignite/groups/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod list; 4 | mod r#move; 5 | pub mod utils; 6 | 7 | use anyhow::Result; 8 | use clap::{Parser, Subcommand}; 9 | 10 | use crate::state::State; 11 | 12 | #[derive(Debug, Subcommand)] 13 | pub enum Commands { 14 | #[clap(name = "new", alias = "create")] 15 | Create(create::Options), 16 | #[clap(name = "rm", alias = "delete")] 17 | Delete(delete::Options), 18 | #[clap(name = "move", alias = "add-deployment", alias = "add")] 19 | Move(r#move::Options), 20 | List(list::Options), 21 | } 22 | 23 | #[derive(Debug, Parser)] 24 | #[clap(about = "Manage groups")] 25 | #[group(skip)] 26 | pub struct Options { 27 | #[clap(subcommand)] 28 | pub commands: Commands, 29 | } 30 | 31 | pub async fn handle(options: Options, state: State) -> Result<()> { 32 | match options.commands { 33 | Commands::Create(options) => create::handle(options, state).await, 34 | Commands::Delete(options) => delete::handle(options, state).await, 35 | Commands::Move(options) => r#move::handle(options, state).await, 36 | Commands::List(options) => list::handle(options, state).await, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/ignite/groups/move.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | use console::style; 4 | 5 | use crate::commands::ignite::groups::utils::{fetch_grouped_deployments, format_groups}; 6 | use crate::commands::ignite::utils::get_deployment; 7 | use crate::config::EXEC_NAME; 8 | use crate::state::State; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Move a Deployment to a group")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(help = "The ID of the group, or \"none\" to remove from a group")] 15 | pub group: Option, 16 | #[clap(help = "Deployment ID to add")] 17 | pub deployment: Option, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let project = state.ctx.current_project_error()?; 22 | 23 | let deployment = if let Some(deployment) = options.deployment { 24 | get_deployment(&state.http, &deployment).await? 25 | } else { 26 | let (deployments_fmt, deployments, validator) = 27 | fetch_grouped_deployments(&state, false, true).await?; 28 | 29 | let idx = loop { 30 | let idx = dialoguer::Select::new() 31 | .with_prompt("Select a deployment") 32 | .items(&deployments_fmt) 33 | .default(0) 34 | .interact()?; 35 | 36 | if let Ok(idx) = validator(idx) { 37 | break idx; 38 | } 39 | 40 | console::Term::stderr().clear_last_lines(1)? 41 | }; 42 | 43 | deployments[idx].to_owned() 44 | }; 45 | 46 | let group = if let Some(group) = options.group { 47 | if group.is_empty() || ["none", "null"].contains(&group.to_lowercase().as_str()) { 48 | None 49 | } else { 50 | Some(group) 51 | } 52 | } else { 53 | let mut groups = state.hop.ignite.groups.get_all(&project.id).await?; 54 | 55 | ensure!( 56 | !groups.is_empty(), 57 | "No groups found, create one with `{EXEC_NAME} ignite groups create`" 58 | ); 59 | 60 | groups.sort_unstable_by_key(|group| group.position); 61 | 62 | let mut formated = format_groups(&groups)?; 63 | 64 | if deployment.group_id.is_some() { 65 | formated.push(style("None (remove from a group)").white().to_string()); 66 | } 67 | 68 | let dialoguer_groups = dialoguer::Select::new() 69 | .with_prompt("Select group") 70 | .default(0) 71 | .items(&formated) 72 | .interact()?; 73 | 74 | if dialoguer_groups == groups.len() - 1 { 75 | None 76 | } else { 77 | Some(groups[dialoguer_groups].id.clone()) 78 | } 79 | }; 80 | 81 | state 82 | .hop 83 | .ignite 84 | .groups 85 | .move_deployment(group.as_deref(), &deployment.id) 86 | .await?; 87 | 88 | log::info!("Added deployment to group"); 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /src/commands/ignite/health/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{create_health_check, create_health_check_config}; 5 | use crate::{commands::ignite::groups::utils::fetch_grouped_deployments, state::State}; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Create Health Checks for a deployment")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(name = "deployment", help = "ID of the deployment")] 12 | pub deployment: Option, 13 | 14 | #[clap(flatten)] 15 | pub health_check: self::HealthCheckCreate, 16 | } 17 | 18 | #[derive(Debug, Parser, PartialEq, Eq, Default)] 19 | pub struct HealthCheckCreate { 20 | #[clap(long, help = "Port to check")] 21 | pub port: Option, 22 | 23 | #[clap(long, help = "Path to check")] 24 | pub path: Option, 25 | 26 | #[clap(long, help = "Interval to check")] 27 | pub interval: Option, 28 | 29 | #[clap(long, help = "Timeout to check")] 30 | pub timeout: Option, 31 | 32 | #[clap(long = "max-retries", help = "Max retries of the check")] 33 | pub max_retries: Option, 34 | 35 | #[clap(long = "initial-delay", help = "Initial delay of the check")] 36 | pub initial_delay: Option, 37 | } 38 | 39 | pub async fn handle(options: Options, state: State) -> Result<()> { 40 | let deployment_id = match options.deployment { 41 | Some(id) => id, 42 | 43 | None => { 44 | let (deployments_fmt, deployments, validator) = 45 | fetch_grouped_deployments(&state, false, true).await?; 46 | 47 | let idx = loop { 48 | let idx = dialoguer::Select::new() 49 | .with_prompt("Select a deployment") 50 | .items(&deployments_fmt) 51 | .default(0) 52 | .interact()?; 53 | 54 | if let Ok(idx) = validator(idx) { 55 | break idx; 56 | } 57 | 58 | console::Term::stderr().clear_last_lines(1)? 59 | }; 60 | 61 | deployments[idx].id.clone() 62 | } 63 | }; 64 | 65 | let health_config = create_health_check_config(options.health_check)?; 66 | 67 | let health_check = create_health_check(&state.http, &deployment_id, health_config).await?; 68 | 69 | log::info!("Created Health Check `{}`", health_check.id); 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/ignite/health/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::{delete_health_check, format_health_checks, get_all_health_checks}; 5 | use crate::{commands::ignite::groups::utils::fetch_grouped_deployments, state::State}; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Delete a Health Check")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(name = "heath-checks", help = "IDs of the Health Check")] 12 | pub health_checks: Vec, 13 | 14 | #[clap(short, long, help = "Skip confirmation")] 15 | force: bool, 16 | } 17 | 18 | pub async fn handle(options: Options, state: State) -> Result<()> { 19 | let health_checks = if !options.health_checks.is_empty() { 20 | options.health_checks 21 | } else { 22 | let (deployments_fmt, deployments, validator) = 23 | fetch_grouped_deployments(&state, false, true).await?; 24 | 25 | let idx = loop { 26 | let idx = dialoguer::Select::new() 27 | .with_prompt("Select a deployment") 28 | .items(&deployments_fmt) 29 | .default(0) 30 | .interact()?; 31 | 32 | if let Ok(idx) = validator(idx) { 33 | break idx; 34 | } 35 | 36 | console::Term::stderr().clear_last_lines(1)? 37 | }; 38 | 39 | let health_checks = get_all_health_checks(&state.http, &deployments[idx].id).await?; 40 | ensure!(!health_checks.is_empty(), "No health checks found"); 41 | let health_checks_fmt = format_health_checks(&health_checks, false); 42 | 43 | let idxs = dialoguer::MultiSelect::new() 44 | .with_prompt("Select a health check") 45 | .items(&health_checks_fmt) 46 | .interact()?; 47 | 48 | health_checks 49 | .iter() 50 | .enumerate() 51 | .filter(|(i, _)| idxs.contains(i)) 52 | .map(|(_, c)| c.id.clone()) 53 | .collect() 54 | }; 55 | 56 | if !options.force 57 | && !dialoguer::Confirm::new() 58 | .with_prompt(format!( 59 | "Are you sure you want to delete {} Health Checks?", 60 | health_checks.len() 61 | )) 62 | .interact_opt()? 63 | .unwrap_or(false) 64 | { 65 | bail!("Aborted"); 66 | } 67 | 68 | let mut delete_count = 0; 69 | 70 | for health_check in &health_checks { 71 | log::info!("Deleting Health Check `{}`", health_check); 72 | 73 | if let Err(err) = delete_health_check(&state.http, health_check).await { 74 | log::error!("Failed to delete Health Check `{}`: {}", health_check, err); 75 | } else { 76 | delete_count += 1; 77 | } 78 | } 79 | 80 | log::info!( 81 | "Deleted {delete_count}/{} Health Check", 82 | health_checks.len() 83 | ); 84 | 85 | Ok(()) 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/ignite/health/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_health_checks, get_all_health_checks}; 5 | use crate::{commands::ignite::groups::utils::fetch_grouped_deployments, state::State}; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List Health Checks in a deployment")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "ID of the deployment")] 12 | pub deployment: Option, 13 | 14 | #[clap(short, long, help = "Only print the IDs of the Health Checks")] 15 | pub quiet: bool, 16 | } 17 | 18 | pub async fn handle(options: Options, state: State) -> Result<()> { 19 | let deployment_id = match options.deployment { 20 | Some(id) => id, 21 | 22 | None => { 23 | let (deployments_fmt, deployments, validator) = 24 | fetch_grouped_deployments(&state, false, true).await?; 25 | 26 | let idx = loop { 27 | let idx = dialoguer::Select::new() 28 | .with_prompt("Select a deployment") 29 | .items(&deployments_fmt) 30 | .default(0) 31 | .interact()?; 32 | 33 | if let Ok(idx) = validator(idx) { 34 | break idx; 35 | } 36 | 37 | console::Term::stderr().clear_last_lines(1)? 38 | }; 39 | 40 | deployments[idx].id.clone() 41 | } 42 | }; 43 | 44 | let health_checks = get_all_health_checks(&state.http, &deployment_id).await?; 45 | 46 | if options.quiet { 47 | let ids = health_checks 48 | .iter() 49 | .map(|d| d.id.as_str()) 50 | .collect::>() 51 | .join(" "); 52 | 53 | println!("{ids}"); 54 | } else { 55 | let health_checks_fmt = format_health_checks(&health_checks, true); 56 | 57 | println!("{}", health_checks_fmt.join("\n")); 58 | } 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/ignite/health/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod list; 4 | mod state; 5 | pub mod types; 6 | pub mod utils; 7 | 8 | use anyhow::Result; 9 | use clap::{Parser, Subcommand}; 10 | 11 | use crate::state::State; 12 | 13 | #[derive(Debug, Subcommand)] 14 | pub enum Commands { 15 | #[clap(name = "new", alias = "create")] 16 | Create(create::Options), 17 | #[clap(alias = "status")] 18 | State(state::Options), 19 | #[clap(name = "rm", alias = "delete")] 20 | Delete(delete::Options), 21 | #[clap(name = "ls", alias = "list")] 22 | List(list::Options), 23 | } 24 | 25 | #[derive(Debug, Parser)] 26 | #[clap(about = "Interact with Ignite Health Checks")] 27 | #[group(skip)] 28 | pub struct Options { 29 | #[clap(subcommand)] 30 | pub commands: Commands, 31 | } 32 | 33 | pub async fn handle(options: Options, state: State) -> Result<()> { 34 | match options.commands { 35 | Commands::Create(options) => create::handle(options, state).await, 36 | Commands::State(options) => state::handle(options, state).await, 37 | Commands::Delete(options) => delete::handle(options, state).await, 38 | Commands::List(options) => list::handle(options, state).await, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/ignite/health/state.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_health_state, get_health_state}; 5 | use crate::{commands::ignite::groups::utils::fetch_grouped_deployments, state::State}; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Create Health Checks for a deployment")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "ID of the Deployment")] 12 | pub deployment: Option, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let deployment_id = match options.deployment { 17 | Some(id) => id, 18 | 19 | None => { 20 | let (deployments_fmt, deployments, validator) = 21 | fetch_grouped_deployments(&state, false, true).await?; 22 | 23 | let idx = loop { 24 | let idx = dialoguer::Select::new() 25 | .with_prompt("Select a deployment") 26 | .items(&deployments_fmt) 27 | .default(0) 28 | .interact()?; 29 | 30 | if let Ok(idx) = validator(idx) { 31 | break idx; 32 | } 33 | 34 | console::Term::stderr().clear_last_lines(1)? 35 | }; 36 | 37 | deployments[idx].id.clone() 38 | } 39 | }; 40 | 41 | let health_state = get_health_state(&state.http, &deployment_id).await?; 42 | let health_state_fmt = format_health_state(&health_state, true); 43 | 44 | println!("{}", health_state_fmt.join("\n")); 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/ignite/health/types.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Serialize)] 5 | pub struct CreateHealthCheck { 6 | pub initial_delay: u64, 7 | pub interval: u64, 8 | pub max_retries: u64, 9 | pub path: String, 10 | pub protocol: String, 11 | pub port: u16, 12 | pub timeout: u64, 13 | pub success_threshold: u64, 14 | } 15 | 16 | impl Default for CreateHealthCheck { 17 | fn default() -> Self { 18 | Self { 19 | initial_delay: 5, 20 | interval: 60, 21 | max_retries: 3, 22 | path: String::from("/"), 23 | protocol: String::from("HTTP"), 24 | port: 8080, 25 | timeout: 50, 26 | success_threshold: 1, 27 | } 28 | } 29 | } 30 | 31 | #[derive(Debug, Deserialize)] 32 | #[serde(rename_all = "lowercase")] 33 | pub enum HealthCheckType { 34 | Liveness, 35 | } 36 | 37 | #[derive(Debug, Deserialize)] 38 | pub struct HealthCheck { 39 | pub id: String, 40 | pub deployment_id: String, 41 | pub initial_delay: u64, 42 | pub interval: u64, 43 | pub max_retries: u64, 44 | pub path: String, 45 | pub protocol: String, 46 | pub port: u64, 47 | pub timeout: u64, 48 | pub success_threshold: u64, 49 | pub created_at: String, 50 | #[serde(rename = "type")] 51 | pub type_: HealthCheckType, 52 | } 53 | 54 | #[derive(Debug, Deserialize)] 55 | pub struct SingleHealthCheck { 56 | pub health_check: HealthCheck, 57 | } 58 | 59 | #[derive(Debug, Deserialize)] 60 | pub struct MultipleHealthChecks { 61 | pub health_checks: Vec, 62 | } 63 | 64 | #[derive(Debug, Deserialize)] 65 | pub struct HealthCheckState { 66 | pub state: String, 67 | pub container_id: String, 68 | pub health_check_id: String, 69 | pub deployment_id: String, 70 | pub created_at: String, 71 | pub next_check: DateTime, 72 | } 73 | 74 | #[derive(Debug, Deserialize)] 75 | pub struct MultipleHealthCheckState { 76 | pub health_check_states: Vec, 77 | } 78 | -------------------------------------------------------------------------------- /src/commands/ignite/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::{ 5 | commands::ignite::{groups::utils::fetch_grouped_deployments, utils::get_all_deployments}, 6 | state::State, 7 | }; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "List all deployments")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(short, long, help = "Only print the IDs of the deployments")] 14 | pub quiet: bool, 15 | } 16 | 17 | pub async fn handle(options: Options, state: State) -> Result<()> { 18 | if options.quiet { 19 | let project_id = state.ctx.current_project_error()?.id; 20 | 21 | let deployments = get_all_deployments(&state.http, &project_id).await?; 22 | 23 | let ids = deployments 24 | .iter() 25 | .map(|d| d.id.as_str()) 26 | .collect::>() 27 | .join(" "); 28 | 29 | println!("{ids}"); 30 | } else { 31 | let deployments_fmt = fetch_grouped_deployments(&state, false, false).await?.0; 32 | 33 | println!("{}", deployments_fmt.join("\n")); 34 | } 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /src/commands/ignite/promote.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::promote; 5 | use crate::commands::ignite::builds::types::BuildState; 6 | use crate::commands::ignite::builds::utils::get_all_builds; 7 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 8 | use crate::state::State; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Rollback containers in a deployment")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(help = "ID of the deployment")] 15 | pub deployment: Option, 16 | 17 | #[clap(help = "ID of the build to rollback to")] 18 | pub build: Option, 19 | } 20 | 21 | pub async fn handle(options: Options, state: State) -> Result<()> { 22 | let deployment_id = match options.deployment { 23 | Some(id) => id, 24 | 25 | None => { 26 | let (deployments_fmt, deployments, validator) = 27 | fetch_grouped_deployments(&state, false, true).await?; 28 | 29 | let idx = loop { 30 | let idx = dialoguer::Select::new() 31 | .with_prompt("Select a deployment") 32 | .items(&deployments_fmt) 33 | .default(0) 34 | .interact()?; 35 | 36 | if let Ok(idx) = validator(idx) { 37 | break idx; 38 | } 39 | 40 | console::Term::stderr().clear_last_lines(1)? 41 | }; 42 | 43 | deployments[idx].id.clone() 44 | } 45 | }; 46 | 47 | let build_id = match options.build { 48 | Some(id) => id, 49 | 50 | None => { 51 | let builds = get_all_builds(&state.http, &deployment_id) 52 | .await? 53 | .into_iter() 54 | .filter(|b| matches!(b.state, BuildState::Succeeded)) 55 | .collect::>(); 56 | 57 | ensure!(!builds.is_empty(), "No successful builds found"); 58 | 59 | let idx = dialoguer::Select::new() 60 | .with_prompt("Select a build") 61 | .items(&builds.iter().map(|b| &b.id).collect::>()) 62 | .default(0) 63 | .interact()?; 64 | 65 | builds[idx].id.clone() 66 | } 67 | }; 68 | 69 | promote(&state.http, &deployment_id, &build_id).await?; 70 | 71 | log::info!("Rolling out new containers"); 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/ignite/rollout.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::rollout; 5 | use crate::{commands::ignite::groups::utils::fetch_grouped_deployments, state::State}; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Rollout new containers to a deployment")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "ID of the deployment")] 12 | pub deployment: Option, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let deployment_id = match options.deployment { 17 | Some(id) => id, 18 | 19 | None => { 20 | let (deployments_fmt, deployments, validator) = 21 | fetch_grouped_deployments(&state, false, true).await?; 22 | 23 | let idx = loop { 24 | let idx = dialoguer::Select::new() 25 | .with_prompt("Select a deployment") 26 | .items(&deployments_fmt) 27 | .default(0) 28 | .interact()?; 29 | 30 | if let Ok(idx) = validator(idx) { 31 | break idx; 32 | } 33 | 34 | console::Term::stderr().clear_last_lines(1)? 35 | }; 36 | 37 | deployments[idx].id.clone() 38 | } 39 | }; 40 | 41 | rollout(&state.http, &deployment_id).await?; 42 | 43 | log::info!("Rolling out new containers"); 44 | 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/ignite/scale.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::scale; 5 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 6 | use crate::commands::ignite::utils::get_deployment; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Scale a deployment")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the deployment to scale")] 14 | pub deployment: Option, 15 | 16 | #[clap(help = "Number of replicas to scale to")] 17 | pub scale: Option, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let deployment = match options.deployment { 22 | Some(id) => get_deployment(&state.http, &id).await?, 23 | 24 | None => { 25 | let (deployments_fmt, deployments, validator) = 26 | fetch_grouped_deployments(&state, false, true).await?; 27 | 28 | let idx = loop { 29 | let idx = dialoguer::Select::new() 30 | .with_prompt("Select a deployment") 31 | .items(&deployments_fmt) 32 | .default(0) 33 | .interact()?; 34 | 35 | if let Ok(idx) = validator(idx) { 36 | break idx; 37 | } 38 | 39 | console::Term::stderr().clear_last_lines(1)? 40 | }; 41 | 42 | deployments[idx].clone() 43 | } 44 | }; 45 | 46 | let scale_count = match options.scale { 47 | Some(scale) => scale, 48 | None => dialoguer::Input::::new() 49 | .with_prompt("Enter the number of containers to scale to") 50 | .default(deployment.container_count) 51 | .interact()?, 52 | }; 53 | 54 | scale(&state.http, &deployment.id, scale_count).await?; 55 | 56 | log::info!("Scaling deployment to {} containers", scale_count); 57 | 58 | Ok(()) 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/ignite/update.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | 4 | use super::create::Options as CreateOptions; 5 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 6 | use crate::commands::ignite::utils::{rollout, scale, update_deployment, update_deployment_config}; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Update a deployment")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "ID of the deployment to update")] 14 | deployment: Option, 15 | 16 | #[clap(flatten)] 17 | config: CreateOptions, 18 | 19 | #[clap(long, help = "Do not roll out the changes, only build")] 20 | no_rollout: bool, 21 | } 22 | 23 | pub async fn handle(options: Options, state: State) -> Result<()> { 24 | let project = state.ctx.current_project_error()?; 25 | 26 | let old_deployment = match options.deployment { 27 | Some(id) => state.get_deployment_by_name_or_id(&id).await?, 28 | 29 | None => { 30 | let (deployments_fmt, deployments, validator) = 31 | fetch_grouped_deployments(&state, false, true).await?; 32 | 33 | let idx = loop { 34 | let idx = dialoguer::Select::new() 35 | .with_prompt("Select a deployment") 36 | .items(&deployments_fmt) 37 | .default(0) 38 | .interact()?; 39 | 40 | if let Ok(idx) = validator(idx) { 41 | break idx; 42 | } 43 | 44 | console::Term::stderr().clear_last_lines(1)? 45 | }; 46 | 47 | deployments[idx].clone() 48 | } 49 | }; 50 | 51 | let is_visual = options.config == CreateOptions::default(); 52 | 53 | let (deployment_config, container_options) = update_deployment_config( 54 | &state.http, 55 | options.config.clone(), 56 | is_visual, 57 | &old_deployment, 58 | &None, 59 | true, 60 | &project, 61 | ) 62 | .await?; 63 | 64 | let mut deployment = update_deployment(&state.http, &old_deployment.id, &deployment_config) 65 | .await 66 | .map_err(|e| anyhow!("Failed to update deployment: {}", e))?; 67 | 68 | if deployment.can_scale() { 69 | if let Some(count) = container_options.containers { 70 | log::info!( 71 | "Updating container count from {} to {}", 72 | old_deployment.container_count, 73 | count 74 | ); 75 | 76 | scale(&state.http, &deployment.id, count).await?; 77 | 78 | deployment.container_count = count; 79 | } 80 | } 81 | 82 | if deployment.can_rollout() && deployment.container_count > 0 && !options.no_rollout { 83 | log::info!("Rolling out new containers"); 84 | rollout(&state.http, &deployment.id).await?; 85 | } 86 | 87 | log::info!( 88 | "Deployment `{}` ({}) updated", 89 | deployment.name, 90 | deployment.id 91 | ); 92 | 93 | Ok(()) 94 | } 95 | -------------------------------------------------------------------------------- /src/commands/link/mod.rs: -------------------------------------------------------------------------------- 1 | use std::env::current_dir; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::{ensure, Result}; 5 | use clap::Parser; 6 | 7 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 8 | use crate::commands::ignite::utils::get_deployment; 9 | use crate::commands::projects::utils::format_project; 10 | use crate::config::EXEC_NAME; 11 | use crate::state::State; 12 | use crate::store::hopfile::HopFile; 13 | 14 | #[derive(Debug, Parser)] 15 | #[clap(about = "Link an existing deployment to a hopfile")] 16 | #[group(skip)] 17 | pub struct Options { 18 | #[clap( 19 | name = "dir", 20 | help = "Directory to link, defaults to current directory" 21 | )] 22 | path: Option, 23 | 24 | #[clap(help = "ID of the deployment")] 25 | deployment: Option, 26 | } 27 | 28 | pub async fn handle(options: Options, state: State) -> Result<()> { 29 | let mut dir = current_dir()?; 30 | 31 | if let Some(path) = options.path { 32 | dir = dir.join(path).canonicalize()?; 33 | } 34 | 35 | ensure!(dir.is_dir(), "{dir:?} is not a directory"); 36 | 37 | if HopFile::find(dir.clone()).await.is_some() { 38 | log::warn!("A hopfile was found {dir:?}, did you mean to `{EXEC_NAME} deploy`?"); 39 | } 40 | 41 | let project = state.ctx.current_project_error()?; 42 | 43 | log::info!("Project: {}", format_project(&project)); 44 | 45 | let deployment = match options.deployment { 46 | Some(id) => get_deployment(&state.http, &id).await?, 47 | 48 | None => { 49 | let (deployments_fmt, deployments, validator) = 50 | fetch_grouped_deployments(&state, false, true).await?; 51 | 52 | let idx = loop { 53 | let idx = dialoguer::Select::new() 54 | .with_prompt("Select a deployment") 55 | .items(&deployments_fmt) 56 | .default(0) 57 | .interact()?; 58 | 59 | if let Ok(idx) = validator(idx) { 60 | break idx; 61 | } 62 | 63 | console::Term::stderr().clear_last_lines(1)? 64 | }; 65 | 66 | deployments[idx].clone() 67 | } 68 | }; 69 | 70 | HopFile::new(dir.join("hop.yml"), &project.id, &deployment.id) 71 | .save() 72 | .await?; 73 | 74 | log::info!( 75 | "Deployment `{}` ({}) linked", 76 | deployment.name, 77 | deployment.id 78 | ); 79 | 80 | Ok(()) 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/oops/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | 4 | use super::ignite::builds::types::BuildState; 5 | use super::ignite::builds::utils::get_all_builds; 6 | use super::ignite::utils::promote; 7 | use crate::commands::ignite::groups::utils::fetch_grouped_deployments; 8 | use crate::commands::projects::utils::format_project; 9 | use crate::state::State; 10 | use crate::store::hopfile::HopFile; 11 | 12 | #[derive(Debug, Parser)] 13 | #[clap(about = "Instantly roll back your deployment to a previous build")] 14 | #[group(skip)] 15 | pub struct Options { 16 | #[clap(help = "ID of the deployment")] 17 | pub deployment: Option, 18 | } 19 | 20 | pub async fn handle(options: &Options, state: State) -> Result<()> { 21 | let deployment_id = if let Some(ref id) = options.deployment { 22 | id.clone() 23 | } else if let Some(hopfile) = HopFile::find_current().await { 24 | hopfile.config.deployment_id 25 | } else { 26 | let project = state.ctx.current_project_error()?; 27 | 28 | log::info!("Using project: {}", format_project(&project)); 29 | 30 | let (deployments_fmt, deployments, validator) = 31 | fetch_grouped_deployments(&state, false, true).await?; 32 | 33 | let idx = loop { 34 | let idx = dialoguer::Select::new() 35 | .with_prompt("Select a deployment") 36 | .items(&deployments_fmt) 37 | .default(0) 38 | .interact()?; 39 | 40 | if let Ok(idx) = validator(idx) { 41 | break idx; 42 | } 43 | 44 | console::Term::stderr().clear_last_lines(1)? 45 | }; 46 | 47 | deployments[idx].id.clone() 48 | }; 49 | 50 | let build_id = if let Some(build) = get_all_builds(&state.http, &deployment_id) 51 | .await? 52 | .into_iter() 53 | .find(|b| matches!(b.state, BuildState::Succeeded)) 54 | { 55 | build.id 56 | } else { 57 | return Err(anyhow!("No successful builds found.")); 58 | }; 59 | 60 | promote(&state.http, &deployment_id, &build_id).await?; 61 | 62 | log::info!("Deployment `{deployment_id}` rolled back to build `{build_id}`"); 63 | 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /src/commands/payment/due.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | 6 | use super::utils::{ 7 | format_payment_methods, get_all_payment_methods, get_all_projects_for_payment_method, 8 | }; 9 | use crate::commands::projects::finance::utils::get_project_balance; 10 | use crate::state::State; 11 | 12 | #[derive(Debug, Parser)] 13 | #[clap(about = "Check how much is due for a payment method(s)")] 14 | #[group(skip)] 15 | pub struct Options { 16 | #[clap( 17 | help = "The ID(s) of the payment method(s), if not provided all payment methods will be used" 18 | )] 19 | pub payment_methods: Vec, 20 | } 21 | 22 | pub async fn handle(options: &Options, state: &State) -> Result<()> { 23 | let payment_methods = get_all_payment_methods(&state.http).await?; 24 | 25 | let payment_methods = if options.payment_methods.is_empty() { 26 | payment_methods 27 | } else { 28 | payment_methods 29 | .into_iter() 30 | .filter(|d| options.payment_methods.contains(&d.id)) 31 | .collect() 32 | }; 33 | 34 | let mut payment_method_balance = HashMap::new(); 35 | 36 | for payment_method in &payment_methods { 37 | let projects = get_all_projects_for_payment_method(&state.http, &payment_method.id).await?; 38 | 39 | let mut balances = Vec::new(); 40 | 41 | for project in projects.into_iter() { 42 | let balance = get_project_balance(&state.http, &project.id).await?; 43 | 44 | balances.push((balance, project)); 45 | } 46 | 47 | payment_method_balance.insert(payment_method.id.clone(), balances); 48 | } 49 | 50 | let payment_methods_fmt = format_payment_methods(&payment_methods, false)?; 51 | 52 | let now = chrono::Local::now().date_naive(); 53 | 54 | for (payment_method_fmt, payment_method) in payment_methods_fmt 55 | .into_iter() 56 | .zip(payment_methods.into_iter()) 57 | { 58 | println!("{payment_method_fmt}"); 59 | 60 | let balances = payment_method_balance.get(&payment_method.id).unwrap(); 61 | 62 | if balances.is_empty() { 63 | println!(" No projects found"); 64 | } 65 | 66 | for (balance, project) in balances.iter() { 67 | let next_billing_date = 68 | chrono::NaiveDate::parse_from_str(&balance.next_billing_cycle, "%Y-%m-%d")? - now; 69 | 70 | print!(" `{}`, ", project.name); 71 | 72 | print!( 73 | "${:.2} due in {} days", 74 | balance.balance.parse::()? - balance.outstanding.parse::()?, 75 | next_billing_date.num_days() 76 | ); 77 | 78 | if balance.outstanding != "0.00" { 79 | print!(" + ${} outstanding", balance.outstanding); 80 | } 81 | 82 | println!(); 83 | } 84 | } 85 | 86 | Ok(()) 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/payment/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{format_payment_methods, get_all_payment_methods}; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List all payment methods")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "Only print the IDs of the payment methods")] 12 | pub quiet: bool, 13 | } 14 | 15 | pub async fn handle(options: &Options, state: &State) -> Result<()> { 16 | let payment_methods = get_all_payment_methods(&state.http).await?; 17 | 18 | if options.quiet { 19 | let ids = payment_methods 20 | .iter() 21 | .map(|d| d.id.as_str()) 22 | .collect::>() 23 | .join(" "); 24 | 25 | println!("{ids}"); 26 | } else { 27 | let payment_methods_fmt = format_payment_methods(&payment_methods, true)?; 28 | 29 | println!("{}", payment_methods_fmt.join("\n")); 30 | } 31 | 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/payment/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod due; 2 | pub mod list; 3 | pub mod types; 4 | pub mod utils; 5 | 6 | use anyhow::Result; 7 | use clap::{Parser, Subcommand}; 8 | 9 | use crate::state::State; 10 | 11 | #[derive(Debug, Subcommand)] 12 | pub enum Commands { 13 | #[clap(name = "ls", alias = "list")] 14 | List(list::Options), 15 | Due(due::Options), 16 | } 17 | 18 | #[derive(Debug, Parser)] 19 | #[clap(about = "Manage payments")] 20 | #[group(skip)] 21 | pub struct Options { 22 | #[clap(subcommand)] 23 | pub commands: Commands, 24 | } 25 | 26 | pub async fn handle(options: Options, state: State) -> Result<()> { 27 | match options.commands { 28 | Commands::List(options) => list::handle(&options, &state).await, 29 | Commands::Due(options) => due::handle(&options, &state).await, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/payment/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize, Clone)] 4 | pub struct PaymentMethod { 5 | pub id: String, 6 | pub brand: String, 7 | pub exp_month: u8, 8 | pub exp_year: u16, 9 | pub last4: u16, 10 | pub default: bool, 11 | } 12 | 13 | #[derive(Debug, Deserialize, Clone)] 14 | pub struct PaymentMethods { 15 | pub payment_methods: Vec, 16 | } 17 | -------------------------------------------------------------------------------- /src/commands/payment/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use tabwriter::TabWriter; 5 | 6 | use super::types::{PaymentMethod, PaymentMethods}; 7 | use crate::commands::projects::types::Project; 8 | use crate::state::http::HttpClient; 9 | use crate::utils::capitalize; 10 | 11 | pub async fn get_all_payment_methods(http: &HttpClient) -> Result> { 12 | let data = http 13 | .request::("GET", "/billing/@me/payment-methods", None) 14 | .await? 15 | .ok_or_else(|| anyhow::anyhow!("Error while parsing response"))? 16 | .payment_methods; 17 | 18 | Ok(data) 19 | } 20 | 21 | pub async fn get_all_projects_for_payment_method( 22 | http: &HttpClient, 23 | payment_method_id: &str, 24 | ) -> Result> { 25 | let data = http 26 | .request::>( 27 | "GET", 28 | &format!("/billing/payment-methods/{payment_method_id}/projects"), 29 | None, 30 | ) 31 | .await? 32 | .ok_or_else(|| anyhow::anyhow!("Error while parsing response"))?; 33 | 34 | Ok(data) 35 | } 36 | 37 | pub fn format_payment_methods( 38 | payment_methods: &[PaymentMethod], 39 | title: bool, 40 | ) -> Result> { 41 | let mut tw = TabWriter::new(vec![]); 42 | 43 | if title { 44 | writeln!(&mut tw, "BRAND\tNUMBER\tEXPIRATION")?; 45 | } 46 | 47 | for payment_method in payment_methods { 48 | let stars = match payment_method.brand.as_str() { 49 | // amex is extra 🙄 50 | "amex" => "**** ****** *", 51 | _ => "**** **** **** ", 52 | }; 53 | 54 | writeln!( 55 | &mut tw, 56 | "{}\t{}{:04}\t{:02}/{}", 57 | capitalize(&payment_method.brand), 58 | stars, 59 | payment_method.last4, 60 | payment_method.exp_month, 61 | payment_method.exp_year, 62 | )?; 63 | } 64 | 65 | let out = String::from_utf8(tw.into_inner().unwrap())? 66 | .lines() 67 | .map(std::string::ToString::to_string) 68 | .collect(); 69 | 70 | Ok(out) 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/projects/create/mod.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | use anyhow::{bail, Result}; 4 | use clap::Parser; 5 | 6 | use crate::commands::projects::create::utils::get_payment_method_from_user; 7 | use crate::commands::projects::utils::{create_project, format_project, validate_namespace}; 8 | use crate::state::State; 9 | use crate::store::Store; 10 | 11 | // TODO: replace when ../new path is implemented 12 | const WEB_PAYMENTS_URL: &str = "https://console.hop.io/settings/cards"; 13 | 14 | #[derive(Debug, Parser)] 15 | #[clap(about = "Create a new project")] 16 | #[group(skip)] 17 | pub struct Options { 18 | #[clap(help = "Namespace of the project")] 19 | namespace: Option, 20 | #[clap(help = "Name of the project")] 21 | name: Option, 22 | #[clap(short, long, help = "Set as default project")] 23 | default: bool, 24 | } 25 | 26 | pub async fn handle(options: Options, mut state: State) -> Result<()> { 27 | let namespace = if let Some(namespace) = options.namespace { 28 | namespace 29 | } else { 30 | dialoguer::Input::new() 31 | .with_prompt("Namespace of the project") 32 | .validate_with(|input: &String| -> Result<()> { validate_namespace(input) }) 33 | .interact_text()? 34 | }; 35 | 36 | let name = if let Some(name) = options.name { 37 | name 38 | } else { 39 | dialoguer::Input::new() 40 | .with_prompt("Name of the project") 41 | .validate_with(|input: &String| -> Result<()> { 42 | if input.len() > 32 { 43 | bail!("Project name must be less than 32 characters") 44 | } 45 | 46 | Ok(()) 47 | }) 48 | .interact_text()? 49 | }; 50 | 51 | let payment_method_id = get_payment_method_from_user(&state.http).await?; 52 | 53 | let project = create_project(&state.http, &name, &namespace, &payment_method_id).await?; 54 | 55 | if options.default { 56 | state.ctx.default_project = Some(project.id.clone()); 57 | state.ctx.save().await?; 58 | } 59 | 60 | log::info!("Created project {}", format_project(&project)); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/projects/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{ensure, Context, Result}; 2 | use clap::Parser; 3 | use serde_json::Value; 4 | 5 | use super::utils::format_projects; 6 | use crate::commands::projects::utils::format_project; 7 | use crate::state::State; 8 | use crate::store::Store; 9 | 10 | static CONFIRM_DELETE_PROJECT_MESSAGE: &str = "I am sure I want to delete the project named "; 11 | 12 | #[derive(Debug, Parser)] 13 | #[clap(about = "Delete a project")] 14 | #[group(skip)] 15 | pub struct Options { 16 | #[clap(help = "Namespace or ID of the project")] 17 | project: Option, 18 | #[clap(short, long, help = "Skip confirmation")] 19 | force: bool, 20 | } 21 | 22 | pub async fn handle(options: Options, mut state: State) -> Result<()> { 23 | let projects = state.ctx.current.clone().unwrap().projects; 24 | 25 | let project = match options.project.clone() { 26 | Some(namespace) => state 27 | .ctx 28 | .find_project_by_id_or_namespace(&namespace) 29 | .with_context(|| format!("Project `{namespace}` not found"))?, 30 | 31 | None => { 32 | let projects_fmt = format_projects(&projects, false); 33 | 34 | let idx = dialoguer::Select::new() 35 | .with_prompt("Select a project") 36 | .items(&projects_fmt) 37 | .default(if let Some(current) = state.ctx.current_project() { 38 | projects 39 | .iter() 40 | .position(|p| p.id == current.id) 41 | .unwrap_or(0) 42 | } else { 43 | 0 44 | }) 45 | .interact()?; 46 | 47 | projects[idx].clone() 48 | } 49 | }; 50 | 51 | if !options.force { 52 | println!( 53 | "To confirm, input the following message `{}{}`", 54 | CONFIRM_DELETE_PROJECT_MESSAGE, project.name 55 | ); 56 | 57 | let output = dialoguer::Input::::new() 58 | .with_prompt("Message") 59 | .interact_text() 60 | .context("Failed to confirm deletion")?; 61 | 62 | ensure!( 63 | output == CONFIRM_DELETE_PROJECT_MESSAGE.to_string() + &project.name, 64 | "Aborted deletion of `{}`", 65 | project.name 66 | ); 67 | } 68 | 69 | state 70 | .http 71 | .request::("DELETE", &format!("/projects/{}", project.id), None) 72 | .await?; 73 | 74 | if state.ctx.default_project == Some(project.id.to_string()) { 75 | state.ctx.default_project = None; 76 | state.ctx.save().await?; 77 | } 78 | 79 | log::info!("Project {} deleted", format_project(&project)); 80 | 81 | Ok(()) 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/projects/finance/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod types; 2 | pub mod utils; 3 | -------------------------------------------------------------------------------- /src/commands/projects/finance/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize)] 4 | pub struct Balance { 5 | pub balance: String, 6 | #[serde(rename = "outstanding_balance")] 7 | pub outstanding: String, 8 | pub next_billing_cycle: String, 9 | } 10 | -------------------------------------------------------------------------------- /src/commands/projects/finance/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use super::types::Balance; 4 | use crate::state::http::HttpClient; 5 | 6 | pub async fn get_project_balance(http: &HttpClient, project_id: &str) -> Result { 7 | let balance = http 8 | .request::( 9 | "GET", 10 | &format!("/projects/{project_id}/finance/balance"), 11 | None, 12 | ) 13 | .await? 14 | .ok_or_else(|| anyhow::anyhow!("Error while parsing response"))?; 15 | 16 | Ok(balance) 17 | } 18 | -------------------------------------------------------------------------------- /src/commands/projects/info.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::commands::projects::utils::format_project; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Get information about a project")] 9 | #[group(skip)] 10 | pub struct Options {} 11 | 12 | pub fn handle(_options: &Options, state: State) -> Result<()> { 13 | let project = state.ctx.current_project_error()?; 14 | 15 | log::info!("Project: {}", format_project(&project)); 16 | 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /src/commands/projects/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | 4 | use super::utils::format_projects; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List all projects")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "Only print the IDs of the projects")] 12 | pub quiet: bool, 13 | } 14 | 15 | pub fn handle(options: Options, state: State) -> Result<()> { 16 | let projects = state.ctx.current.context("You are not logged in")?.projects; 17 | 18 | if options.quiet { 19 | let ids = projects 20 | .iter() 21 | .map(|d| d.id.as_str()) 22 | .collect::>() 23 | .join(" "); 24 | 25 | println!("{ids}"); 26 | } else { 27 | let projects_fmt = format_projects(&projects, true); 28 | 29 | println!("{}", projects_fmt.join("\n")); 30 | } 31 | 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/projects/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | pub mod finance; 4 | pub mod info; 5 | mod list; 6 | mod switch; 7 | pub mod types; 8 | pub mod utils; 9 | 10 | use clap::{Parser, Subcommand}; 11 | 12 | use crate::state::State; 13 | 14 | #[derive(Debug, Subcommand)] 15 | pub enum Commands { 16 | #[clap(name = "new", alias = "create")] 17 | Create(create::Options), 18 | Switch(switch::Options), 19 | Info(info::Options), 20 | #[clap(name = "ls", alias = "list")] 21 | List(list::Options), 22 | #[clap(name = "rm", alias = "delete")] 23 | Delete(delete::Options), 24 | } 25 | 26 | #[derive(Debug, Parser)] 27 | #[clap(about = "Interact with projects")] 28 | #[group(skip)] 29 | pub struct Options { 30 | #[clap(subcommand)] 31 | pub commands: Commands, 32 | } 33 | 34 | pub async fn handle(options: Options, state: State) -> anyhow::Result<()> { 35 | match options.commands { 36 | Commands::Switch(options) => switch::handle(options, state).await, 37 | Commands::Delete(options) => delete::handle(options, state).await, 38 | Commands::Create(options) => create::handle(options, state).await, 39 | Commands::List(options) => list::handle(options, state), 40 | Commands::Info(options) => info::handle(&options, state), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/projects/switch.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::projects::utils::{format_project, format_projects}; 5 | use crate::state::State; 6 | use crate::store::Store; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Switch to a different project")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(help = "Namespace or ID of the project to use")] 13 | pub project: Option, 14 | } 15 | 16 | pub async fn handle(options: Options, mut state: State) -> Result<()> { 17 | let projects = state.ctx.current.clone().unwrap().projects; 18 | 19 | let project = match options.project.clone() { 20 | Some(namespace) => state 21 | .ctx 22 | .find_project_by_id_or_namespace(&namespace) 23 | .with_context(|| format!("Project `{namespace}` not found"))?, 24 | None => { 25 | let projects_fmt = format_projects(&projects, false); 26 | 27 | let idx = dialoguer::Select::new() 28 | .with_prompt("Select a project") 29 | .items(&projects_fmt) 30 | .default(if let Some(current) = state.ctx.current_project() { 31 | projects 32 | .iter() 33 | .position(|p| p.id == current.id) 34 | .unwrap_or(0) 35 | } else { 36 | 0 37 | }) 38 | .interact()?; 39 | 40 | projects[idx].clone() 41 | } 42 | }; 43 | 44 | state.ctx.default_project = Some(project.id.clone()); 45 | state.ctx.save().await?; 46 | 47 | log::info!("Switched to project {}", format_project(&project)); 48 | 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/projects/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::{bail, Context, Result}; 4 | use regex::Regex; 5 | use tabwriter::TabWriter; 6 | 7 | use super::types::{CreateProject, Project, Quotas, SingleProjectResponse, Sku, SkuResponse}; 8 | use crate::state::http::HttpClient; 9 | 10 | pub fn format_projects(projects: &Vec, title: bool) -> Vec { 11 | let mut tw = TabWriter::new(vec![]); 12 | 13 | if title { 14 | writeln!(&mut tw, "NAME\tNAMESPACE\tID\tCREATED\tTYPE").unwrap(); 15 | } 16 | 17 | for project in projects { 18 | writeln!( 19 | &mut tw, 20 | "{}\t/{}\t{}\t{}\t{}", 21 | project.name.clone(), 22 | project.namespace, 23 | project.id, 24 | project.created_at, 25 | project.type_, 26 | ) 27 | .unwrap(); 28 | } 29 | 30 | String::from_utf8(tw.into_inner().unwrap()) 31 | .unwrap() 32 | .lines() 33 | .map(std::string::ToString::to_string) 34 | .collect() 35 | } 36 | 37 | pub fn format_project(project: &Project) -> String { 38 | format_projects(&vec![project.clone()], false)[0].clone() 39 | } 40 | 41 | pub async fn create_project( 42 | http: &HttpClient, 43 | name: &str, 44 | namespace: &str, 45 | payment_method_id: &str, 46 | ) -> Result { 47 | let data = http 48 | .request::( 49 | "POST", 50 | "/projects", 51 | Some(( 52 | serde_json::to_vec(&CreateProject { 53 | name: name.to_string(), 54 | namespace: namespace.to_string(), 55 | payment_method_id: payment_method_id.to_string(), 56 | }) 57 | .unwrap() 58 | .into(), 59 | "application/json", 60 | )), 61 | ) 62 | .await? 63 | .ok_or_else(|| anyhow::anyhow!("Error while parsing response"))? 64 | .project; 65 | 66 | Ok(data) 67 | } 68 | 69 | pub fn validate_namespace(namespace: &str) -> Result<()> { 70 | let regex = Regex::new(r"(?i)[a-z0-9_]")?; 71 | 72 | if namespace.len() > 15 { 73 | bail!("Namespace must be less than 15 characters") 74 | } else if !regex.is_match(namespace) { 75 | bail!("Namespace must contain only letters, numbers and underscores") 76 | } 77 | 78 | Ok(()) 79 | } 80 | 81 | pub async fn get_quotas(http: &HttpClient, project_id: &str) -> Result { 82 | http.request::("GET", &format!("/quotas?project={project_id}"), None) 83 | .await? 84 | .context("Error while parsing response") 85 | } 86 | 87 | pub async fn get_skus(http: &HttpClient) -> Result> { 88 | Ok(http 89 | .request::("GET", "/skus", None) 90 | .await? 91 | .context("Error while parsing response")? 92 | .skus) 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/secrets/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, ensure, Result}; 2 | use clap::Parser; 3 | use serde_json::Value; 4 | 5 | use crate::commands::secrets::types::Secrets; 6 | use crate::commands::secrets::utils::validate_name; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Delete a secret")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(help = "Name of the secret")] 14 | name: Option, 15 | #[clap(short, long, help = "Skip confirmation")] 16 | force: bool, 17 | } 18 | 19 | pub async fn handle(options: Options, state: State) -> Result<()> { 20 | if let Some(ref name) = options.name { 21 | validate_name(name).unwrap(); 22 | } 23 | 24 | let project_id = state.ctx.current_project_error()?.id; 25 | 26 | let secret_name = match options.name { 27 | Some(name) => name, 28 | None => { 29 | let secrets = state 30 | .http 31 | .request::("GET", &format!("/projects/{project_id}/secrets"), None) 32 | .await? 33 | .unwrap() 34 | .secrets; 35 | 36 | ensure!(!secrets.is_empty(), "No secrets found"); 37 | 38 | let secrets_fmt = secrets 39 | .iter() 40 | .map(|s| format!(" {} ({})", s.name, s.id)) 41 | .collect::>(); 42 | 43 | let idx = dialoguer::Select::new() 44 | .with_prompt("Select a secret") 45 | .items(&secrets_fmt) 46 | .default(0) 47 | .interact()?; 48 | 49 | secrets[idx].name.clone() 50 | } 51 | }; 52 | 53 | if !options.force 54 | && !dialoguer::Confirm::new() 55 | .with_prompt(format!( 56 | "Are you sure you want to delete secret `{secret_name}`?" 57 | )) 58 | .interact_opt()? 59 | .unwrap_or(false) 60 | { 61 | bail!("Aborted"); 62 | } 63 | 64 | state 65 | .http 66 | .request::( 67 | "DELETE", 68 | &format!("/projects/{project_id}/secrets/{secret_name}"), 69 | None, 70 | ) 71 | .await?; 72 | 73 | log::info!("Secret `{}` deleted", secret_name); 74 | 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/secrets/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use crate::commands::secrets::types::Secrets; 5 | use crate::commands::secrets::utils::format_secrets; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "List all secrets")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(short, long, help = "Only print the IDs of the secrets")] 13 | pub quiet: bool, 14 | } 15 | 16 | pub async fn handle(options: Options, state: State) -> Result<()> { 17 | let project_id = state.ctx.current_project_error()?.id; 18 | 19 | let secrets = state 20 | .http 21 | .request::("GET", &format!("/projects/{project_id}/secrets"), None) 22 | .await? 23 | .unwrap() 24 | .secrets; 25 | 26 | if options.quiet { 27 | let ids = secrets 28 | .iter() 29 | .map(|d| d.id.as_str()) 30 | .collect::>() 31 | .join(" "); 32 | 33 | println!("{ids}"); 34 | } else { 35 | let secrets_fmt = format_secrets(&secrets, true); 36 | 37 | println!("{}", secrets_fmt.join("\n")); 38 | } 39 | 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/secrets/mod.rs: -------------------------------------------------------------------------------- 1 | mod delete; 2 | mod list; 3 | mod set; 4 | mod types; 5 | pub mod utils; 6 | 7 | use anyhow::Result; 8 | use clap::{Parser, Subcommand}; 9 | 10 | use crate::state::State; 11 | 12 | #[derive(Debug, Subcommand)] 13 | pub enum Commands { 14 | #[clap(name = "set", alias = "create", alias = "update", alias = "new")] 15 | Set(set::Options), 16 | #[clap(name = "ls", alias = "list")] 17 | List(list::Options), 18 | #[clap(name = "rm", alias = "del", alias = "delete", alias = "remove")] 19 | Delete(delete::Options), 20 | } 21 | 22 | #[derive(Debug, Parser)] 23 | #[clap(about = "Interact with secrets")] 24 | #[group(skip)] 25 | pub struct Options { 26 | #[clap(subcommand)] 27 | pub commands: Commands, 28 | } 29 | 30 | pub async fn handle(options: Options, state: State) -> Result<()> { 31 | match options.commands { 32 | Commands::List(options) => list::handle(options, state).await, 33 | Commands::Set(options) => set::handle(options, state).await, 34 | Commands::Delete(options) => delete::handle(options, state).await, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/secrets/set.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::secrets::types::SecretResponse; 5 | use crate::commands::secrets::utils::validate_name; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Set a secret")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(help = "Name of the secret")] 13 | name: String, 14 | #[clap(help = "Value of the secret")] 15 | value: String, 16 | } 17 | 18 | pub async fn handle(options: Options, state: State) -> Result<()> { 19 | validate_name(&options.name)?; 20 | 21 | let project_id = state.ctx.current_project_error()?.id; 22 | 23 | let secret = state 24 | .http 25 | .request::( 26 | "PUT", 27 | &format!( 28 | "/projects/{project_id}/secrets/{}", 29 | options.name.to_uppercase() 30 | ), 31 | Some((options.value.into(), "text/plain")), 32 | ) 33 | .await? 34 | .ok_or_else(|| anyhow!("Error while parsing response"))? 35 | .secret; 36 | 37 | log::info!("Set secret: {} ({})", secret.name, secret.id); 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/secrets/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize, Clone)] 4 | pub struct Secret { 5 | pub id: String, 6 | pub name: String, 7 | pub digest: String, 8 | pub created_at: String, 9 | } 10 | 11 | #[derive(Debug, Deserialize)] 12 | pub struct Secrets { 13 | pub secrets: Vec, 14 | } 15 | 16 | #[derive(Debug, Deserialize, Clone)] 17 | pub struct SecretResponse { 18 | pub secret: Secret, 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/secrets/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::{bail, Result}; 4 | use regex::Regex; 5 | use tabwriter::TabWriter; 6 | 7 | use super::types::Secret; 8 | 9 | pub fn validate_name(name: &str) -> Result<()> { 10 | let regex = regex::Regex::new(r"(?i)^[a-z0-9_]{1,64}$").unwrap(); 11 | 12 | if !regex.is_match(name) { 13 | bail!("Invalid name. Secret names are limited to 64 characters in length, must be alphanumeric (with underscores) and are automatically uppercased.") 14 | } 15 | 16 | Ok(()) 17 | } 18 | 19 | pub fn format_secrets(secrets: &Vec, title: bool) -> Vec { 20 | let mut tw = TabWriter::new(vec![]); 21 | 22 | if title { 23 | writeln!(&mut tw, "NAME\tID\tCREATED").unwrap(); 24 | } 25 | 26 | for secret in secrets { 27 | writeln!( 28 | &mut tw, 29 | "{}\t{}\t{}", 30 | secret.name, secret.id, secret.created_at 31 | ) 32 | .unwrap(); 33 | } 34 | 35 | String::from_utf8(tw.into_inner().unwrap()) 36 | .unwrap() 37 | .lines() 38 | .map(std::string::ToString::to_string) 39 | .collect() 40 | } 41 | 42 | pub fn get_secret_name(secret: &str) -> Option { 43 | let regex = Regex::new(r"(?i)^\$\{secrets\.(\w+)}$").unwrap(); 44 | 45 | regex.captures(secret).map(|c| c[1].to_string()) 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/tunnel/types.rs: -------------------------------------------------------------------------------- 1 | use serde::de::Error as SerdeDeError; 2 | use serde::ser::Error as SerdeSerError; 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::json; 5 | use serde_repr::Deserialize_repr; 6 | 7 | #[derive(Debug, Serialize, Deserialize_repr)] 8 | #[repr(u8)] 9 | pub enum OpCodes { 10 | Auth = 1, 11 | Connect = 2, 12 | Unknown = !0, 13 | } 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum TonneruPacket { 17 | Auth { 18 | token: String, 19 | resource_id: String, 20 | port: u16, 21 | }, 22 | Connect { 23 | resource_id: String, 24 | }, 25 | } 26 | 27 | impl<'de> Deserialize<'de> for TonneruPacket { 28 | fn deserialize(deserializer: D) -> Result 29 | where 30 | D: serde::Deserializer<'de>, 31 | { 32 | let mut gw_event = serde_json::Map::deserialize(deserializer)?; 33 | 34 | let op_code = gw_event 35 | .remove("op") 36 | .ok_or_else(|| serde::de::Error::missing_field("op")) 37 | .and_then(OpCodes::deserialize) 38 | .map_err(SerdeDeError::custom)?; 39 | 40 | match op_code { 41 | OpCodes::Connect => { 42 | let data = gw_event 43 | .remove("d") 44 | .ok_or_else(|| serde::de::Error::missing_field("d"))?; 45 | 46 | let data = data 47 | .as_object() 48 | .ok_or_else(|| serde::de::Error::custom("d is not an object"))?; 49 | 50 | let container_id = data 51 | .get("container_id") 52 | .ok_or_else(|| serde::de::Error::missing_field("container_id"))?; 53 | 54 | let container_id = container_id 55 | .as_str() 56 | .ok_or_else(|| serde::de::Error::custom("container_id is not a string"))?; 57 | 58 | Ok(TonneruPacket::Connect { 59 | resource_id: container_id.to_string(), 60 | }) 61 | } 62 | _ => Err(SerdeDeError::custom("invalid opcode received")), 63 | } 64 | } 65 | } 66 | 67 | impl Serialize for TonneruPacket { 68 | fn serialize(&self, serializer: S) -> Result 69 | where 70 | S: serde::Serializer, 71 | { 72 | match self { 73 | Self::Auth { 74 | token, 75 | resource_id, 76 | port, 77 | } => { 78 | let packet = json!({ 79 | "op": OpCodes::Auth as u8, 80 | "d": { 81 | "token": token, 82 | "resource_id": resource_id, 83 | "port": port, 84 | } 85 | }); 86 | 87 | packet.serialize(serializer) 88 | } 89 | 90 | _ => Err(SerdeSerError::custom("invalid opcode sent"))?, 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/update/command.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Result; 5 | use clap::Parser; 6 | use tokio::fs; 7 | 8 | use super::checker::{check_version, now_secs}; 9 | use super::types::Version; 10 | use super::util::{ 11 | create_completions_commands, download, execute_commands, swap_exe_command, unpack, 12 | HOP_CLI_DOWNLOAD_URL, 13 | }; 14 | use crate::config::{ARCH, VERSION}; 15 | use crate::state::http::HttpClient; 16 | use crate::state::State; 17 | use crate::store::Store; 18 | use crate::utils::capitalize; 19 | 20 | #[derive(Debug, Parser)] 21 | #[clap(about = "Update Hop to the latest version")] 22 | #[group(skip)] 23 | pub struct Options { 24 | #[clap(short, long, help = "Force update")] 25 | pub force: bool, 26 | 27 | #[clap(short, long, help = "Update to beta version (if available)")] 28 | pub beta: bool, 29 | } 30 | 31 | #[cfg(feature = "update")] 32 | pub async fn handle(options: Options, mut state: State) -> Result<()> { 33 | let http = HttpClient::new(None, None); 34 | 35 | let (update, version) = check_version(&Version::from_string(VERSION)?, options.beta).await?; 36 | 37 | if !update && !options.force { 38 | log::info!("CLI is up to date"); 39 | return Ok(()); 40 | } 41 | 42 | log::info!("Found new version {version} (current: {VERSION})"); 43 | 44 | let platform = capitalize(&sys_info::os_type().unwrap_or_else(|_| "Unknown".to_string())); 45 | 46 | // download the new release 47 | let packed_temp = download( 48 | &http, 49 | HOP_CLI_DOWNLOAD_URL, 50 | &format!("v{version}"), 51 | &format!("hop-{ARCH}-{platform}"), 52 | ) 53 | .await?; 54 | 55 | // unpack the new release 56 | let unpacked = unpack(&packed_temp, "hop").await?; 57 | 58 | // remove the tarball since it's no longer needed 59 | fs::remove_file(packed_temp).await?; 60 | 61 | let mut non_elevated_args: Vec = vec![]; 62 | let mut elevated_args: Vec = vec![]; 63 | 64 | let mut current = std::env::current_exe()? 65 | .canonicalize()? 66 | .to_string_lossy() 67 | .to_string(); 68 | 69 | if current.starts_with(r"\\\\?\\") { 70 | current = current[7..].to_string(); 71 | } else if current.starts_with(r"\\?\") { 72 | current = current[4..].to_string(); 73 | } 74 | 75 | let current = PathBuf::from(current); 76 | 77 | log::debug!("Current executable: {current:?}"); 78 | 79 | // swap the executables 80 | swap_exe_command( 81 | &mut non_elevated_args, 82 | &mut elevated_args, 83 | current.clone(), 84 | unpacked, 85 | ) 86 | .await; 87 | 88 | // create completions 89 | create_completions_commands(&mut non_elevated_args, &mut elevated_args, current).await; 90 | 91 | // execute the commands 92 | execute_commands(&non_elevated_args, &elevated_args).await?; 93 | 94 | state.ctx.last_version_check = Some((now_secs()?.to_string(), version.to_string())); 95 | state.ctx.save().await?; 96 | 97 | log::info!("Updated to {version}"); 98 | 99 | Ok(()) 100 | } 101 | -------------------------------------------------------------------------------- /src/commands/update/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod checker; 2 | #[cfg(feature = "update")] 3 | mod command; 4 | mod parse; 5 | pub mod types; 6 | pub mod util; 7 | 8 | pub use self::checker::version_notice; 9 | #[cfg(feature = "update")] 10 | pub use self::command::*; 11 | -------------------------------------------------------------------------------- /src/commands/update/parse.rs: -------------------------------------------------------------------------------- 1 | use std::num::ParseIntError; 2 | 3 | pub fn version(version: &str) -> Result<(u16, u16, u16, Option), ParseIntError> { 4 | let tag = if let Some(stripped) = version.strip_prefix('v') { 5 | stripped 6 | } else { 7 | version 8 | }; 9 | 10 | let mut pre = tag.split('-'); 11 | let mut parts = pre.next().unwrap_or(tag).split('.'); 12 | 13 | let major = parts.next().unwrap_or("0").parse()?; 14 | let minor = parts.next().unwrap_or("0").parse()?; 15 | let patch = parts.next().unwrap_or("0").parse()?; 16 | 17 | let prelease = match pre.next() { 18 | Some(prelease) => Some(prelease.parse()?), 19 | None => None, 20 | }; 21 | 22 | Ok((major, minor, patch, prelease)) 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/volumes/backup.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | 4 | use super::copy::fslike::FsLike; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Backup files from a deployment to local machine")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(help = "Deployment name or id")] 12 | pub source: String, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let source = FsLike::from_str(&state, &format!("{}:/", options.source)).await?; 17 | 18 | let backup_file = dirs::download_dir() 19 | .or(dirs::home_dir().map(|home| home.join("Downloads"))) 20 | .context("Could not find a download directory")? 21 | .join(format!( 22 | "hop-backup_{}_{}.tar.gz", 23 | options.source, 24 | chrono::Local::now().format("%Y-%m-%d_%H-%M-%S") 25 | )) 26 | .to_string_lossy() 27 | .to_string(); 28 | 29 | let (_, data) = source.read().await?; 30 | 31 | tokio::fs::write(&backup_file, data) 32 | .await 33 | .with_context(|| format!("Could not write to {backup_file}"))?; 34 | 35 | log::info!("Backup saved to {backup_file}"); 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/volumes/copy/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod fslike; 2 | mod utils; 3 | 4 | use anyhow::{bail, Result}; 5 | use clap::Parser; 6 | 7 | use self::fslike::FsLike; 8 | use crate::state::State; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Copy files between volumes and local machine")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(help = "Source, in the format :/ or if local")] 15 | pub source: String, 16 | #[clap(help = "Target, in the format :/ or if local")] 17 | pub target: String, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let source = FsLike::from_str(&state, &options.source).await?; 22 | let target = FsLike::from_str(&state, &options.target).await?; 23 | 24 | // because users could just use `cp` to copy files between local directories 25 | if source.is_local() && target.is_local() { 26 | bail!("Specify at least one remote path"); 27 | } 28 | 29 | // temporary limitation 30 | if !source.is_local() && !target.is_local() { 31 | bail!("Specify at least one local path"); 32 | } 33 | 34 | let transfer_size = source.to(target).await?; 35 | 36 | log::info!( 37 | "Copied from {} to {} ({} bytes)", 38 | options.source, 39 | options.target, 40 | transfer_size 41 | ); 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/volumes/copy/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{bail, Context, Result}; 4 | use reqwest::multipart::{Form, Part}; 5 | use serde_json::Value; 6 | 7 | use crate::commands::volumes::utils::path_into_uri_safe; 8 | use crate::state::http::HttpClient; 9 | 10 | pub async fn send_files_to_volume<'l>( 11 | http: &HttpClient, 12 | deployment: &str, 13 | volume: &str, 14 | path: &str, 15 | data: Vec, 16 | packed: bool, 17 | ) -> Result<()> { 18 | let url = format!("/ignite/deployments/{deployment}/volumes/{volume}/files",); 19 | 20 | let (path, filename) = if packed { 21 | (path, "archive.zip".to_string()) 22 | } else { 23 | let buf = PathBuf::from(path); 24 | 25 | ( 26 | path, 27 | buf.file_name() 28 | .context("No file name")? 29 | .to_str() 30 | .context("Invalid file name")? 31 | .to_string(), 32 | ) 33 | }; 34 | 35 | let form = Form::new() 36 | .part("file", Part::bytes(data).file_name(filename)) 37 | .part("path", Part::text(path.to_string())); 38 | 39 | log::debug!("Packed: {}", packed); 40 | 41 | let response = http 42 | .client 43 | .post(format!("{}{url}", http.base_url)) 44 | .header("X-No-Unpacking", (!packed).to_string()) 45 | .multipart(form) 46 | .send() 47 | .await?; 48 | 49 | // since it should be a 204, we don't need to parse the response data 50 | http.handle_response::(response).await?; 51 | 52 | Ok(()) 53 | } 54 | 55 | pub async fn get_files_from_volume( 56 | http: &HttpClient, 57 | deployment: &str, 58 | volume: &str, 59 | path: &str, 60 | ) -> Result<(bool, Vec)> { 61 | let path = path_into_uri_safe(path); 62 | 63 | let url = format!("/ignite/deployments/{deployment}/volumes/{volume}/files/{path}"); 64 | 65 | let response = http 66 | .client 67 | .get(format!("{}{url}", http.base_url)) 68 | .query(&[("stream", "true")]) 69 | .send() 70 | .await?; 71 | 72 | log::debug!("Response headers: {:#?}", response.headers()); 73 | 74 | // check header for content type 75 | let packed = response 76 | .headers() 77 | .get("x-directory") 78 | .map(|x| x.to_str()) 79 | .transpose()? 80 | // if the header is not present, assume false (not packed) 81 | .unwrap_or("false") 82 | .to_lowercase() 83 | == "true"; 84 | 85 | let data = match response.status() { 86 | reqwest::StatusCode::OK => response.bytes().await?, 87 | reqwest::StatusCode::NOT_FOUND => bail!("File not found"), 88 | status => { 89 | // bogus type 90 | http.handle_error::>(response, status).await?; 91 | 92 | unreachable!("handle_error should have returned an error"); 93 | } 94 | }; 95 | 96 | Ok((packed, data.to_vec())) 97 | } 98 | -------------------------------------------------------------------------------- /src/commands/volumes/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::{delete_files_for_path, parse_target_from_path_like, path_into_uri_safe}; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Delete files")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap( 12 | help = "The path(s) to delete, in the format :", 13 | required = true 14 | )] 15 | pub paths: Vec, 16 | } 17 | 18 | pub async fn handle(options: Options, state: State) -> Result<()> { 19 | for file in options.paths { 20 | let target = parse_target_from_path_like(&state, &file).await?; 21 | 22 | match target { 23 | (Some((deployment, volume)), path) => { 24 | let path = path_into_uri_safe(&path); 25 | 26 | delete_files_for_path(&state.http, &deployment.id, &volume, &path).await?; 27 | 28 | log::info!("Deleted `{file}`"); 29 | } 30 | 31 | (None, _) => { 32 | log::warn!("No deployment identifier found in `{file}`, skipping"); 33 | } 34 | } 35 | } 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/volumes/list.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io::Write; 3 | 4 | use anyhow::Result; 5 | use clap::Parser; 6 | use tabwriter::TabWriter; 7 | 8 | use super::types::Files; 9 | use super::utils::{format_file, get_files_for_path, parse_target_from_path_like}; 10 | use crate::state::State; 11 | 12 | #[derive(Debug, Parser)] 13 | #[clap(about = "List information about files")] 14 | #[group(skip)] 15 | pub struct Options { 16 | #[clap( 17 | help = "The path(s) to list, in the format :", 18 | required = true 19 | )] 20 | pub paths: Vec, 21 | #[clap(short, long, help = "Use a long listing format")] 22 | pub long: bool, 23 | #[clap(short, long, help = "Do not ignore entries starting with .")] 24 | pub all: bool, 25 | } 26 | 27 | pub async fn handle(options: Options, state: State) -> Result<()> { 28 | let mut files_map = HashMap::new(); 29 | 30 | for file in options.paths { 31 | let target = parse_target_from_path_like(&state, &file).await?; 32 | 33 | let (deployment, volume, path) = match target { 34 | (Some((deployment, volume)), path) => (deployment, volume, path), 35 | (None, _) => { 36 | log::warn!("No deployment identifier found in `{file}`, skipping, make sure to use the format :"); 37 | 38 | continue; 39 | } 40 | }; 41 | 42 | files_map.insert( 43 | file.clone(), 44 | get_files_for_path(&state.http, &deployment.id, &volume, &path).await?, 45 | ); 46 | } 47 | 48 | let is_mult_checked = files_map.len() > 1; 49 | let mut is_first_element = true; 50 | 51 | let mut tw = TabWriter::new(std::io::stdout()); 52 | 53 | for (path, files) in files_map { 54 | if !is_first_element { 55 | writeln!(tw)?; 56 | } else { 57 | is_first_element = false; 58 | } 59 | 60 | match files { 61 | Files::Single { mut file } => { 62 | if options.long { 63 | file.name = path; 64 | 65 | writeln!(tw, "{}", format_file(&file)?)?; 66 | } else { 67 | writeln!(tw, "{path}")?; 68 | } 69 | } 70 | Files::Multiple { mut file } => { 71 | if is_mult_checked { 72 | writeln!(tw, "{path}:")?; 73 | } 74 | 75 | if !options.all { 76 | file.retain(|x| !x.name.starts_with('.')); 77 | } 78 | 79 | for file in file { 80 | if options.long { 81 | writeln!(tw, "{}", format_file(&file)?)?; 82 | } else { 83 | write!(tw, "{}\t", file.name)?; 84 | } 85 | } 86 | 87 | if !options.long { 88 | writeln!(tw)?; 89 | } 90 | } 91 | } 92 | } 93 | 94 | // flush the tabwriter to stdout 95 | tw.flush()?; 96 | 97 | Ok(()) 98 | } 99 | -------------------------------------------------------------------------------- /src/commands/volumes/mkdir.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::parse_target_from_path_like; 5 | use crate::commands::volumes::utils::create_directory; 6 | use crate::state::State; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Delete files")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap( 13 | help = "The path(s) to delete, in the format :", 14 | required = true 15 | )] 16 | pub paths: Vec, 17 | 18 | #[clap( 19 | short, 20 | long, 21 | help = "Create recursive directories if they do not exist" 22 | )] 23 | pub recursive: bool, 24 | } 25 | 26 | pub async fn handle(options: Options, state: State) -> Result<()> { 27 | for file in options.paths { 28 | let target = parse_target_from_path_like(&state, &file).await?; 29 | 30 | match target { 31 | (Some((deployment, volume)), path) => { 32 | create_directory( 33 | &state.http, 34 | &deployment.id, 35 | &volume, 36 | &path, 37 | options.recursive, 38 | ) 39 | .await?; 40 | 41 | log::info!("Created directory `{file}`"); 42 | } 43 | 44 | (None, _) => { 45 | log::warn!("No deployment identifier found in `{file}`, skipping"); 46 | } 47 | } 48 | } 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/volumes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod backup; 2 | mod copy; 3 | mod delete; 4 | mod list; 5 | mod mkdir; 6 | mod r#move; 7 | mod types; 8 | mod utils; 9 | 10 | use anyhow::Result; 11 | use clap::{Parser, Subcommand}; 12 | 13 | use crate::state::State; 14 | 15 | #[derive(Debug, Subcommand)] 16 | pub enum Commands { 17 | #[clap(name = "ls", alias = "list", alias = "dir")] 18 | List(list::Options), 19 | #[clap(name = "cp", alias = "copy")] 20 | Copy(copy::Options), 21 | #[clap(name = "rm", alias = "delete")] 22 | Delete(delete::Options), 23 | #[clap(name = "mv", alias = "move")] 24 | Move(r#move::Options), 25 | Mkdir(mkdir::Options), 26 | Backup(backup::Options), 27 | } 28 | 29 | #[derive(Debug, Parser)] 30 | #[clap( 31 | about = "Interact with Volumes\n hop volumes ls :\n hop volumes cp :\n hop volumes rm :" 32 | )] 33 | #[group(skip)] 34 | pub struct Options { 35 | #[clap(subcommand)] 36 | pub commands: Commands, 37 | } 38 | 39 | pub async fn handle(options: Options, state: State) -> Result<()> { 40 | match options.commands { 41 | Commands::List(options) => list::handle(options, state).await, 42 | Commands::Copy(options) => copy::handle(options, state).await, 43 | Commands::Delete(options) => delete::handle(options, state).await, 44 | Commands::Move(options) => r#move::handle(options, state).await, 45 | Commands::Mkdir(options) => mkdir::handle(options, state).await, 46 | Commands::Backup(options) => backup::handle(options, state).await, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/volumes/move.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Parser; 3 | 4 | use crate::state::State; 5 | 6 | use super::utils::{move_file, parse_target_from_path_like}; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "List information about files")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(help = "The path to move")] 13 | pub source: String, 14 | #[clap(help = "The path to move to")] 15 | pub target: String, 16 | } 17 | 18 | /// Handle is writte so it allows: 19 | /// hop volumes mv : : 20 | /// hop volumes mv : 21 | /// 22 | /// Which makes it easy for the user, since they don't have to specify the deployment but can 23 | pub async fn handle(options: Options, state: State) -> Result<()> { 24 | let source = parse_target_from_path_like(&state, &options.source).await?; 25 | let target = parse_target_from_path_like(&state, &options.target).await?; 26 | 27 | let (deployment, volume) = if let Some((deployment, volume)) = source.0 { 28 | (deployment, volume) 29 | } else { 30 | bail!("No deployment identifier found in `{}`", options.source); 31 | }; 32 | 33 | if let Some((deployment_target, volume_target)) = target.0 { 34 | if deployment_target.id != deployment.id || volume_target != volume { 35 | bail!("Cannot move files between deployments"); 36 | } 37 | } 38 | 39 | move_file(&state.http, &deployment.id, &volume, &source.1, &target.1).await?; 40 | 41 | log::info!("Moved `{}` to `{}`", source.1, target.1); 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/volumes/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Clone)] 4 | #[serde(untagged)] 5 | pub enum Files { 6 | Single { file: File }, 7 | Multiple { file: Vec }, 8 | } 9 | 10 | #[derive(Debug, Deserialize, Clone)] 11 | pub struct File { 12 | pub name: String, 13 | pub directory: bool, 14 | pub permissions: u64, 15 | pub created_at: String, 16 | pub updated_at: String, 17 | pub size: u64, 18 | } 19 | 20 | #[derive(Debug, Serialize, Clone)] 21 | pub struct MoveRequest { 22 | #[serde(rename = "oldPath")] 23 | pub source: String, 24 | #[serde(rename = "newPath")] 25 | pub target: String, 26 | } 27 | 28 | #[derive(Debug, Serialize, Clone)] 29 | pub struct CreateDirectory { 30 | #[serde(rename = "name")] 31 | pub path: String, 32 | pub recursive: bool, 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/webhooks/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | use hop::webhooks::types::{PossibleEvents, EVENT_NAMES}; 4 | 5 | use super::utils::string_to_event; 6 | use crate::commands::webhooks::utils::get_formatted_events; 7 | use crate::state::State; 8 | use crate::utils::urlify; 9 | 10 | #[derive(Debug, Parser)] 11 | #[clap(about = "Create a new webhook")] 12 | #[group(skip)] 13 | pub struct Options { 14 | #[clap(help = "The url to send the webhook to")] 15 | pub url: Option, 16 | #[clap(short, long, help = "The events to send the webhook on", value_parser = string_to_event )] 17 | pub events: Vec, 18 | } 19 | 20 | pub async fn handle(options: Options, state: State) -> Result<()> { 21 | let project = state.ctx.current_project_error()?; 22 | 23 | let url = if let Some(url) = options.url { 24 | url 25 | } else { 26 | dialoguer::Input::new() 27 | .with_prompt("Webhook URL") 28 | .interact_text()? 29 | }; 30 | 31 | let events = if !options.events.is_empty() { 32 | options.events 33 | } else { 34 | let dialoguer_events = loop { 35 | let idxs = dialoguer::MultiSelect::new() 36 | .with_prompt("Select events") 37 | .items(&get_formatted_events()?) 38 | .interact()?; 39 | 40 | if !idxs.is_empty() { 41 | break idxs; 42 | } 43 | }; 44 | 45 | EVENT_NAMES 46 | .into_iter() 47 | .enumerate() 48 | .filter(|(idx, _)| dialoguer_events.contains(idx)) 49 | .map(|(_, (event, _))| event) 50 | .collect() 51 | }; 52 | 53 | let webhook = state 54 | .hop 55 | .webhooks 56 | .create(&project.id, &url, &events) 57 | .await?; 58 | 59 | log::info!("Webhook successfully created. ID: {}\n", webhook.id); 60 | log::info!("This is your webhook's secret, this is how you will authenticate traffic coming to your endpoint"); 61 | log::info!("Webhook Header: {}", urlify("X-Hop-Hooks-Signature")); 62 | log::info!( 63 | "Webhook Secret: {}", 64 | urlify( 65 | &webhook 66 | .secret 67 | .context("Webhook secret was expected to be present")? 68 | ) 69 | ); 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/webhooks/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::webhooks::utils::format_webhooks; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Delete a webhook")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "The id of the webhook")] 12 | pub id: Option, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let project = state.ctx.current_project_error()?; 17 | 18 | let all = state.hop.webhooks.get_all(&project.id).await?; 19 | 20 | let webhook = if let Some(id) = options.id { 21 | all.into_iter() 22 | .find(|webhook| webhook.id == id) 23 | .context("Webhook not found")? 24 | } else { 25 | let formatted_webhooks = format_webhooks(&all, false); 26 | 27 | let idx = dialoguer::Select::new() 28 | .with_prompt("Select a webhook to update") 29 | .items(&formatted_webhooks) 30 | .default(0) 31 | .interact()?; 32 | 33 | all[idx].clone() 34 | }; 35 | 36 | state.hop.webhooks.delete(&project.id, &webhook.id).await?; 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/webhooks/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | use super::utils::format_webhooks; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "List webhooks")] 9 | #[group(skip)] 10 | pub struct Options { 11 | #[clap(short, long, help = "Only print the IDs")] 12 | pub quiet: bool, 13 | } 14 | 15 | pub async fn handle(options: Options, state: State) -> Result<()> { 16 | let project = state.ctx.current_project_error()?; 17 | 18 | let webhooks = state.hop.webhooks.get_all(&project.id).await?; 19 | 20 | if options.quiet { 21 | let ids = webhooks 22 | .iter() 23 | .map(|d| d.id.as_str()) 24 | .collect::>() 25 | .join(" "); 26 | 27 | println!("{ids}"); 28 | } else { 29 | let webhooks_fmt = format_webhooks(&webhooks, true); 30 | 31 | println!("{}", webhooks_fmt.join("\n")); 32 | } 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/webhooks/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod list; 4 | mod regenerate; 5 | mod update; 6 | mod utils; 7 | 8 | use anyhow::Result; 9 | use clap::{Parser, Subcommand}; 10 | 11 | use crate::state::State; 12 | 13 | #[derive(Debug, Subcommand)] 14 | pub enum Commands { 15 | #[clap(name = "ls", alias = "list")] 16 | List(list::Options), 17 | #[clap(name = "new", alias = "create", alias = "add")] 18 | Create(create::Options), 19 | #[clap(name = "update", alias = "edit")] 20 | Update(update::Options), 21 | #[clap(name = "rm", alias = "delete", alias = "del")] 22 | Delete(delete::Options), 23 | #[clap(name = "regenerate", alias = "regen")] 24 | Regenerate(regenerate::Options), 25 | } 26 | 27 | #[derive(Debug, Parser)] 28 | #[clap(about = "Manage webhooks")] 29 | #[group(skip)] 30 | pub struct Options { 31 | #[clap(subcommand)] 32 | pub commands: Commands, 33 | } 34 | 35 | pub async fn handle(options: Options, state: State) -> Result<()> { 36 | match options.commands { 37 | Commands::List(options) => list::handle(options, state).await, 38 | Commands::Create(options) => create::handle(options, state).await, 39 | Commands::Update(options) => update::handle(options, state).await, 40 | Commands::Delete(options) => delete::handle(options, state).await, 41 | Commands::Regenerate(options) => regenerate::handle(options, state).await, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/webhooks/regenerate.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::webhooks::utils::format_webhooks; 5 | use crate::state::State; 6 | use crate::utils::urlify; 7 | 8 | #[derive(Debug, Parser)] 9 | #[clap(about = "Regenerate a webhook secret")] 10 | #[group(skip)] 11 | pub struct Options { 12 | #[clap(short, long, help = "The id of the webhook")] 13 | pub id: Option, 14 | } 15 | 16 | pub async fn handle(options: Options, state: State) -> Result<()> { 17 | let project = state.ctx.current_project_error()?; 18 | 19 | let all = state.hop.webhooks.get_all(&project.id).await?; 20 | 21 | let webhook = if let Some(id) = options.id { 22 | all.into_iter() 23 | .find(|webhook| webhook.id == id) 24 | .context("Webhook not found")? 25 | } else { 26 | let formatted_webhooks = format_webhooks(&all, false); 27 | 28 | let idx = dialoguer::Select::new() 29 | .with_prompt("Select a webhook to update") 30 | .items(&formatted_webhooks) 31 | .default(0) 32 | .interact()?; 33 | 34 | all[idx].clone() 35 | }; 36 | 37 | let token = state 38 | .hop 39 | .webhooks 40 | .regenerate_secret(&project.id, &webhook.id) 41 | .await?; 42 | 43 | log::info!("This is your webhook's secret, this is how you will authenticate traffic coming to your endpoint"); 44 | log::info!("Webhook Header: {}", urlify("X-Hop-Hooks-Signature")); 45 | log::info!("Webhook Secret: {}", urlify(&token)); 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/webhooks/update.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | use hop::webhooks::types::{PossibleEvents, EVENT_NAMES}; 4 | 5 | use super::utils::string_to_event; 6 | use crate::commands::webhooks::utils::{format_webhooks, get_formatted_events}; 7 | use crate::state::State; 8 | 9 | #[derive(Debug, Parser)] 10 | #[clap(about = "Update a webhook")] 11 | #[group(skip)] 12 | pub struct Options { 13 | #[clap(short, long, help = "The id of the webhook")] 14 | pub id: Option, 15 | #[clap(short, long, help = "The url to send the webhook to")] 16 | pub url: Option, 17 | #[clap(short, long, help = "The events to send the webhook on", value_parser = string_to_event )] 18 | pub events: Vec, 19 | } 20 | 21 | pub async fn handle(options: Options, state: State) -> Result<()> { 22 | let project = state.ctx.current_project_error()?; 23 | 24 | let all = state.hop.webhooks.get_all(&project.id).await?; 25 | 26 | let old = if let Some(id) = options.id { 27 | all.into_iter() 28 | .find(|webhook| webhook.id == id) 29 | .context("Webhook not found")? 30 | } else { 31 | let formatted_webhooks = format_webhooks(&all, false); 32 | 33 | let idx = dialoguer::Select::new() 34 | .with_prompt("Select a webhook") 35 | .items(&formatted_webhooks) 36 | .default(0) 37 | .interact()?; 38 | 39 | all[idx].clone() 40 | }; 41 | 42 | let url = if let Some(url) = options.url { 43 | url 44 | } else { 45 | dialoguer::Input::new() 46 | .with_prompt("Webhook URL") 47 | .default(old.webhook_url) 48 | .interact_text()? 49 | }; 50 | 51 | let events = if !options.events.is_empty() { 52 | options.events 53 | } else { 54 | let dialoguer_events = loop { 55 | let idxs = dialoguer::MultiSelect::new() 56 | .with_prompt("Select events") 57 | .items(&get_formatted_events()?) 58 | .defaults(&EVENT_NAMES.map(|(event, _)| old.events.contains(&event))) 59 | .interact()?; 60 | 61 | if !idxs.is_empty() { 62 | break idxs; 63 | } 64 | }; 65 | 66 | EVENT_NAMES 67 | .into_iter() 68 | .enumerate() 69 | .filter(|(idx, _)| dialoguer_events.binary_search(idx).is_ok()) 70 | .map(|(_, (event, _))| event) 71 | .collect() 72 | }; 73 | 74 | let webhook = state 75 | .hop 76 | .webhooks 77 | .patch(&project.id, &old.id, &url, &events) 78 | .await?; 79 | 80 | log::info!("Webhook successfully created. ID: {}", webhook.id); 81 | 82 | Ok(()) 83 | } 84 | -------------------------------------------------------------------------------- /src/commands/webhooks/utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use hop::webhooks::types::{PossibleEvents, Webhook, EVENT_CATEGORIES, EVENT_NAMES}; 5 | use tabwriter::TabWriter; 6 | 7 | pub fn format_webhooks(webhooks: &[Webhook], title: bool) -> Vec { 8 | let mut tw = TabWriter::new(vec![]); 9 | 10 | if title { 11 | writeln!(&mut tw, "ID\tURL\tACTIVE EVENTS").unwrap(); 12 | } 13 | 14 | for webhook in webhooks { 15 | writeln!( 16 | &mut tw, 17 | "{}\t{}\t{}", 18 | webhook.id, 19 | webhook.webhook_url, 20 | webhook.events.len() 21 | ) 22 | .unwrap(); 23 | } 24 | 25 | String::from_utf8(tw.into_inner().unwrap()) 26 | .unwrap() 27 | .lines() 28 | .map(std::string::ToString::to_string) 29 | .collect() 30 | } 31 | 32 | pub fn string_to_event(string: &str) -> Result { 33 | serde_json::from_str(string).map_err(|e| e.into()) 34 | } 35 | 36 | pub fn get_formatted_events() -> Result> { 37 | let mut events = vec![]; 38 | 39 | let mut start_idx = 0usize; 40 | 41 | for (name, end_idx) in EVENT_CATEGORIES { 42 | let end_idx = end_idx as usize + start_idx; 43 | 44 | for (_, event) in &EVENT_NAMES[start_idx..end_idx] { 45 | events.push(format!("{name}: {event}")); 46 | } 47 | 48 | start_idx = end_idx; 49 | } 50 | 51 | Ok(events) 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/whoami/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | 4 | use crate::commands::projects::info; 5 | use crate::state::State; 6 | 7 | #[derive(Debug, Parser)] 8 | #[clap(about = "Get information about the current user")] 9 | #[group(skip)] 10 | pub struct Options {} 11 | 12 | pub fn handle(_options: &Options, state: State) -> Result<()> { 13 | let authorized = state 14 | .ctx 15 | .current 16 | .clone() 17 | .ok_or_else(|| anyhow!("You are not logged in"))?; 18 | 19 | log::info!( 20 | "You are logged in as `{}` ({})", 21 | authorized.name, 22 | authorized.email 23 | ); 24 | 25 | info::handle(&info::Options {}, state) 26 | } 27 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | pub const ARCH: &str = std::env::consts::ARCH; 2 | pub const PLATFORM: &str = std::env::consts::OS; 3 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 4 | 5 | #[cfg(not(windows))] 6 | pub const EXEC_NAME: &str = "hop"; 7 | #[cfg(windows)] 8 | pub const EXEC_NAME: &str = "hop.exe"; 9 | pub const LEAP_PROJECT: &str = "project_MzA0MDgwOTQ2MDEwODQ5NzQ"; 10 | 11 | #[cfg(windows)] 12 | pub const DEFAULT_EDITOR: &str = "notepad.exe"; 13 | #[cfg(not(windows))] 14 | pub const DEFAULT_EDITOR: &str = "vi"; 15 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod commands; 2 | pub(crate) mod config; 3 | pub(crate) mod state; 4 | pub(crate) mod store; 5 | pub(crate) mod utils; 6 | 7 | use anyhow::Result; 8 | use clap::Parser; 9 | use commands::update::version_notice; 10 | use commands::{handle_command, Commands}; 11 | use config::{ARCH, PLATFORM, VERSION}; 12 | use state::{State, StateOptions}; 13 | 14 | #[derive(Debug, Parser)] 15 | #[clap( 16 | name = "hop", 17 | about = "🐇 Interact with Hop via command line", 18 | version, 19 | author 20 | )] 21 | pub struct CLI { 22 | #[clap(subcommand)] 23 | pub commands: Commands, 24 | 25 | #[clap( 26 | short, 27 | long, 28 | help = "Namespace or ID of the project to use", 29 | global = true 30 | )] 31 | pub project: Option, 32 | 33 | #[clap(short = 'D', long, help = "Enable debug mode", global = true)] 34 | pub debug: bool, 35 | } 36 | 37 | pub async fn run() -> Result<()> { 38 | // create a new CLI instance 39 | let cli = CLI::parse(); 40 | 41 | // setup panic hook 42 | utils::set_hook(); 43 | 44 | utils::logs(cli.debug); 45 | 46 | // in the debug mode, print the version and arch for easier debugging 47 | log::debug!("Hop-CLI v{VERSION} build for {ARCH}-{PLATFORM}"); 48 | 49 | utils::sudo::fix().await?; 50 | 51 | let state = State::new(StateOptions { 52 | override_project: std::env::var("PROJECT_ID").ok().or(cli.project), 53 | override_token: std::env::var("TOKEN").ok(), 54 | debug: cli.debug, 55 | }) 56 | .await?; 57 | 58 | match cli.commands { 59 | #[cfg(feature = "update")] 60 | Commands::Update(_) => {} 61 | 62 | // do not show the notice if we are in completions mode 63 | // since it could break the shell 64 | Commands::Completions(_) => {} 65 | 66 | // only show the notice if we are not in debug mode or in CI 67 | _ if cfg!(debug_assertions) || state.is_ci => {} 68 | 69 | // async block to spawn the update check in the background :) 70 | _ => { 71 | let ctx = state.ctx.clone(); 72 | 73 | tokio::spawn(async move { 74 | if let Err(e) = version_notice(ctx).await { 75 | log::debug!("Failed to check for updates: {e}"); 76 | } 77 | }); 78 | } 79 | }; 80 | 81 | if let Err(error) = handle_command(cli.commands, state).await { 82 | log::error!("{error}"); 83 | log::debug!("{error:#?}"); 84 | std::process::exit(1); 85 | } 86 | 87 | utils::clean_term(); 88 | 89 | Ok(()) 90 | } 91 | 92 | #[cfg(test)] 93 | mod test { 94 | #[test] 95 | fn test_cli() { 96 | use clap::CommandFactory; 97 | 98 | use super::*; 99 | 100 | CLI::command().debug_assert(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::pedantic, clippy::nursery)] // clippy::cargo is removed because of dependency issues :( (tokio and hyper) 2 | 3 | #[tokio::main] 4 | async fn main() -> anyhow::Result<()> { 5 | #[cfg(debug_assertions)] 6 | let now = tokio::time::Instant::now(); 7 | 8 | // a lib level function 9 | // for proper type checking 10 | hop_cli::run().await?; 11 | 12 | #[cfg(debug_assertions)] 13 | log::debug!("Finished in {:#?}", now.elapsed()); 14 | 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /src/state/http/types.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Deserialize)] 4 | pub struct Base { 5 | pub success: bool, 6 | pub data: T, 7 | } 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct ErrorContent { 11 | pub code: String, 12 | pub message: String, 13 | } 14 | 15 | #[derive(Debug, Deserialize)] 16 | pub struct ErrorResponse { 17 | pub error: ErrorContent, 18 | } 19 | -------------------------------------------------------------------------------- /src/store/auth.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Result; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::fs::{self, File}; 7 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 8 | 9 | use super::utils::home_path; 10 | use super::Storable; 11 | use crate::impl_store; 12 | 13 | #[derive(Debug, Serialize, Deserialize, Clone, Default)] 14 | pub struct Auth { 15 | pub authorized: HashMap, 16 | } 17 | 18 | impl Storable for Auth { 19 | fn path() -> Result { 20 | home_path(".hop/auth.json") 21 | } 22 | } 23 | 24 | impl_store!(Auth); 25 | -------------------------------------------------------------------------------- /src/store/context.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{anyhow, Context as _, Result}; 4 | use serde::{Deserialize, Serialize}; 5 | use tokio::fs::{self, File}; 6 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 7 | 8 | use super::utils::home_path; 9 | use super::Storable; 10 | use crate::commands::auth::types::AuthorizedClient; 11 | use crate::commands::projects::types::Project; 12 | use crate::config::EXEC_NAME; 13 | use crate::impl_store; 14 | 15 | #[derive(Debug, Serialize, Deserialize, Clone, Default)] 16 | pub struct Context { 17 | /// stored in the context store file 18 | pub default_project: Option, 19 | /// stored in the context store file 20 | pub default_user: Option, 21 | /// api url override, only save if its not null 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | pub override_api_url: Option, 24 | // latest version of the cli and time it was last checked 25 | pub last_version_check: Option<(String, String)>, 26 | 27 | /// runtime context 28 | #[serde(skip)] 29 | pub current: Option, 30 | /// runtime context 31 | #[serde(skip)] 32 | pub project_override: Option, 33 | } 34 | 35 | impl Storable for Context { 36 | fn path() -> Result { 37 | home_path(".hop/context.json") 38 | } 39 | } 40 | 41 | impl_store!(Context); 42 | 43 | impl Context { 44 | pub fn find_project_by_id_or_namespace(&self, id_or_namespace: &str) -> Option { 45 | self.current 46 | .as_ref() 47 | .and_then(|me| { 48 | me.projects.iter().find(|p| { 49 | p.id == id_or_namespace 50 | || p.namespace.to_lowercase() == id_or_namespace.to_lowercase() 51 | }) 52 | }) 53 | .cloned() 54 | } 55 | 56 | pub fn current_project(&self) -> Option { 57 | match self.project_override.clone() { 58 | Some(project) => self.find_project_by_id_or_namespace(&project), 59 | 60 | None => self 61 | .default_project 62 | .clone() 63 | .and_then(|id| self.find_project_by_id_or_namespace(&id)), 64 | } 65 | } 66 | 67 | #[inline] 68 | pub fn current_project_error(&self) -> Result { 69 | self.current_project().with_context(|| anyhow!("No project specified, run `{EXEC_NAME} projects switch` or use --project to specify a project")) 70 | } 71 | 72 | // for future use with external package managers 73 | #[cfg(feature = "update")] 74 | pub fn update_command(&self) -> String { 75 | format!("{EXEC_NAME} update") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/store/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! impl_store { 3 | ($($name:ty),+ $(,)?) => ($( 4 | #[async_trait::async_trait] 5 | impl $crate::store::Store for $name { 6 | async fn new() -> Result { 7 | use anyhow::{Context as _}; 8 | 9 | let path = Self::path()?; 10 | 11 | if fs::metadata(path.clone()).await.is_err() { 12 | return Self::default().save().await; 13 | } 14 | 15 | let mut file = File::open(path.clone()) 16 | .await 17 | .context("Error opening file")?; 18 | 19 | let mut buffer = String::new(); 20 | file.read_to_string(&mut buffer).await?; 21 | 22 | serde_json::from_str(&buffer).context(format!("Failed to deserialize {} store", stringify!($name))) 23 | } 24 | 25 | async fn save(&self) -> Result { 26 | use anyhow::{Context as _}; 27 | 28 | let path = Self::path()?; 29 | 30 | fs::create_dir_all(path.parent().context("Failed to get store directory")?) 31 | .await 32 | .context("Failed to create store directory")?; 33 | 34 | let mut file = File::create(path.clone()) 35 | .await 36 | .context("Error opening file")?; 37 | 38 | file.write_all( 39 | serde_json::to_string(&self) 40 | .context(format!("Failed to serialize {} store", stringify!($name)))? 41 | .as_bytes(), 42 | ) 43 | .await 44 | .context("Failed to write store")?; 45 | 46 | Ok(self.clone()) 47 | } 48 | } 49 | )+) 50 | } 51 | -------------------------------------------------------------------------------- /src/store/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::Result; 4 | use async_trait::async_trait; 5 | use serde::de::DeserializeOwned; 6 | use serde::Serialize; 7 | 8 | pub mod auth; 9 | pub mod context; 10 | pub mod hopfile; 11 | pub mod macros; 12 | pub mod utils; 13 | 14 | pub trait Storable { 15 | fn path() -> Result; 16 | } 17 | 18 | #[async_trait] 19 | pub trait Store { 20 | // custom trait with its type to implement a macro easily 21 | #[allow(clippy::new_ret_no_self)] 22 | async fn new() -> Result; 23 | async fn save(&self) -> Result; 24 | } 25 | -------------------------------------------------------------------------------- /src/store/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Context, Result}; 4 | 5 | pub fn home_path(to_join: &str) -> Result { 6 | let path = dirs::home_dir() 7 | .context("Could not find `home` directory")? 8 | .join(to_join); 9 | 10 | log::debug!("Home path + joined: {:?}", path); 11 | 12 | Ok(path) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/browser.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | use std::future::Future; 3 | use std::pin::Pin; 4 | 5 | use anyhow::{ensure, Result}; 6 | use hyper::service::{make_service_fn, service_fn}; 7 | use hyper::{Body, Request, Response, Server}; 8 | use tokio::sync::mpsc::{channel, Sender}; 9 | 10 | type RequestHandler = 11 | fn( 12 | Request, 13 | Sender, 14 | ) -> Pin, Infallible>> + Send>>; 15 | 16 | pub async fn listen_for_callback( 17 | port: u16, 18 | timeout_min: u16, 19 | request_handler: RequestHandler, 20 | ) -> Result { 21 | let (sender, mut receiver) = channel::(1); 22 | 23 | let timeouter = sender.clone(); 24 | 25 | let timeout = tokio::spawn(async move { 26 | let timeout = timeout_min as u64 * 60; 27 | 28 | tokio::time::sleep(tokio::time::Duration::from_secs(timeout)).await; 29 | timeouter.send("timeout".to_string()).await.ok(); 30 | }); 31 | 32 | let service = make_service_fn(move |_| { 33 | let sender = sender.clone(); 34 | 35 | async move { 36 | Ok::<_, Infallible>(service_fn(move |req: Request| { 37 | request_handler(req, sender.clone()) 38 | })) 39 | } 40 | }); 41 | 42 | let address = ([127, 0, 0, 1], port).into(); 43 | 44 | let server = Server::bind(&address).serve(service); 45 | 46 | let runtime = tokio::spawn(async move { 47 | if let Err(error) = server.await { 48 | log::error!("Server error: {error}"); 49 | } 50 | 51 | timeout.abort(); 52 | }); 53 | 54 | let response = receiver.recv().await; 55 | 56 | runtime.abort(); 57 | 58 | ensure!( 59 | Some("timeout".to_string()) != response, 60 | "Timed out after {timeout_min} minutes" 61 | ); 62 | 63 | Ok(response.unwrap()) 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/deser.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer}; 2 | 3 | pub fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result 4 | where 5 | T: Default + Deserialize<'de>, 6 | D: Deserializer<'de>, 7 | { 8 | Option::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) 9 | } 10 | 11 | pub fn deserialize_string_to_f64<'de, D>(deserializer: D) -> Result 12 | where 13 | D: serde::Deserializer<'de>, 14 | { 15 | String::deserialize(deserializer)? 16 | .parse::() 17 | .map_err(serde::de::Error::custom) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/sudo.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use tokio::process::Command; 3 | 4 | pub async fn fix() -> Result<()> { 5 | // check if in sudo and user real user home 6 | if let Ok(user) = std::env::var("USER") { 7 | if user != "root" { 8 | return Ok(()); // not in sudo 9 | } 10 | 11 | if let Ok(user) = std::env::var("SUDO_USER") { 12 | log::debug!("Running as SUDO, using home of `{user}`"); 13 | 14 | // running ~user to get home path 15 | let home = Command::new("sh") 16 | .arg("-c") 17 | .arg(format!("eval echo ~{user}")) 18 | .output() 19 | .await 20 | .with_context(|| format!("Failed to get home path of `{user}`"))? 21 | .stdout; 22 | 23 | let home = String::from_utf8(home)?; 24 | 25 | log::debug!("Setting home to `{home}`"); 26 | 27 | // set home path 28 | std::env::set_var("HOME", home.trim()); 29 | } else { 30 | log::debug!("Running as root without sudo, using home `{user}`"); 31 | } 32 | } 33 | 34 | Ok(()) 35 | } 36 | --------------------------------------------------------------------------------