mirror of https://github.com/buster-so/buster.git
Add macOS ARM64 support and enhance CLI authentication and version management
This commit is contained in:
parent
c6d70b62dc
commit
caec707450
|
@ -27,6 +27,10 @@ jobs:
|
|||
target: x86_64-apple-darwin
|
||||
artifact_name: buster-cli-darwin-x86_64.tar.gz
|
||||
use_tar: true
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: buster-cli-darwin-arm64.tar.gz
|
||||
use_tar: true
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: buster-cli-windows-x86_64.zip
|
||||
|
@ -116,6 +120,8 @@ jobs:
|
|||
**/buster-cli-linux-x86_64.tar.gz.sha256
|
||||
**/buster-cli-darwin-x86_64.tar.gz
|
||||
**/buster-cli-darwin-x86_64.tar.gz.sha256
|
||||
**/buster-cli-darwin-arm64.tar.gz
|
||||
**/buster-cli-darwin-arm64.tar.gz.sha256
|
||||
**/buster-cli-windows-x86_64.zip
|
||||
**/buster-cli-windows-x86_64.zip.sha256
|
||||
draft: false
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "buster-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[lib]
|
||||
name = "buster_cli"
|
||||
|
@ -11,24 +12,33 @@ path = "src/lib.rs"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1.0.79"
|
||||
clap = { version = "4.4.18", features = ["derive"] }
|
||||
clap = { version = "4.4.18", features = ["derive", "env"] }
|
||||
confy = "0.6.0"
|
||||
dirs = "5.0.1"
|
||||
futures = "0.3.29"
|
||||
indicatif = "0.17.8"
|
||||
inquire = "0.7.5"
|
||||
lazy_static = "1.4.0"
|
||||
ratatui = "0.29.0"
|
||||
regex = "1.10.3"
|
||||
reqwest = { version = "0.12.9", features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "stream"] }
|
||||
rpassword = "7.3.1"
|
||||
serde = { version = "1.0.196", features = ["derive"] }
|
||||
serde_json = "1.0.113"
|
||||
serde_yaml = "0.9.34"
|
||||
tempfile = "3.10.0"
|
||||
thiserror = "2.0.3"
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.10", features = ["io"] }
|
||||
futures-util = "0.3.30"
|
||||
uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
||||
colored = "3.0"
|
||||
rustls = { version = "0.22", features = ["tls12"] }
|
||||
rustls = { version = "0.23", features = ["tls12"] }
|
||||
url = "2.5.0"
|
||||
zip = "2.2.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.16.0"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4"
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
use chrono::Utc;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Set the build date
|
||||
let build_date = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
|
||||
|
||||
// Get the git hash if possible
|
||||
let git_hash = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=GIT_HASH={}", git_hash.trim());
|
||||
|
||||
// Tell cargo to rerun this if any git changes occur
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads/main");
|
||||
}
|
|
@ -1,60 +1,132 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use inquire::{Password, Text};
|
||||
use std::env;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::utils::{
|
||||
buster_credentials::{get_buster_credentials, set_buster_credentials, BusterCredentials},
|
||||
BusterClient,
|
||||
};
|
||||
|
||||
pub async fn auth() -> Result<()> {
|
||||
// Get the API key from the credentials file.
|
||||
// If the file doesn't exist, set it to the default.
|
||||
let mut buster_creds = match get_buster_credentials().await {
|
||||
Ok(buster_creds) => buster_creds,
|
||||
Err(_) => match set_buster_credentials(BusterCredentials::default()).await {
|
||||
Ok(_) => get_buster_credentials().await?,
|
||||
Err(e) => anyhow::bail!("Failed to set credentials: {}", e),
|
||||
},
|
||||
};
|
||||
const DEFAULT_HOST: &str = "https://api2.buster.so";
|
||||
|
||||
let url_input = Text::new("Enter the URL of your Buster API")
|
||||
.with_default(&buster_creds.url)
|
||||
.prompt()?;
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthError {
|
||||
#[error("URL is required")]
|
||||
MissingUrl,
|
||||
#[error("API key is required")]
|
||||
MissingApiKey,
|
||||
#[error("Invalid API key")]
|
||||
InvalidApiKey,
|
||||
#[error("Failed to validate credentials: {0}")]
|
||||
ValidationError(String),
|
||||
#[error("Failed to save credentials: {0}")]
|
||||
StorageError(String),
|
||||
}
|
||||
|
||||
// If no URL at all, error
|
||||
if url_input.is_empty() {
|
||||
anyhow::bail!("URL is required");
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about = "Authenticate with Buster API")]
|
||||
pub struct AuthArgs {
|
||||
/// The Buster API host URL
|
||||
#[arg(long, env = "BUSTER_HOST")]
|
||||
pub host: Option<String>,
|
||||
|
||||
/// Your Buster API key
|
||||
#[arg(long, env = "BUSTER_API_KEY")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// Don't save credentials to disk
|
||||
#[arg(long)]
|
||||
pub no_save: bool,
|
||||
}
|
||||
|
||||
async fn validate_credentials(url: &str, api_key: &str) -> Result<(), AuthError> {
|
||||
let buster_client = BusterClient::new(url.to_string(), api_key.to_string())
|
||||
.map_err(|e| AuthError::ValidationError(e.to_string()))?;
|
||||
|
||||
if !buster_client.validate_api_key().await
|
||||
.map_err(|e| AuthError::ValidationError(e.to_string()))? {
|
||||
return Err(AuthError::InvalidApiKey);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth() -> Result<()> {
|
||||
let args = AuthArgs::parse();
|
||||
auth_with_args(args).await
|
||||
}
|
||||
|
||||
pub async fn auth_with_args(args: AuthArgs) -> Result<()> {
|
||||
// Get existing credentials or create default
|
||||
let mut buster_creds = match get_buster_credentials().await {
|
||||
Ok(creds) => creds,
|
||||
Err(_) => BusterCredentials {
|
||||
url: DEFAULT_HOST.to_string(),
|
||||
api_key: String::new(),
|
||||
},
|
||||
};
|
||||
|
||||
// Apply host from args or use default
|
||||
if let Some(host) = args.host {
|
||||
buster_creds.url = host;
|
||||
}
|
||||
|
||||
// Apply API key from args or environment
|
||||
if let Some(api_key) = args.api_key {
|
||||
buster_creds.api_key = api_key;
|
||||
}
|
||||
|
||||
// Interactive mode for missing values
|
||||
if buster_creds.url.is_empty() {
|
||||
let url_input = Text::new("Enter the URL of your Buster API")
|
||||
.with_default(DEFAULT_HOST)
|
||||
.with_help_message("Press Enter to use the default URL")
|
||||
.prompt()
|
||||
.context("Failed to get URL input")?;
|
||||
|
||||
if url_input.is_empty() {
|
||||
buster_creds.url = DEFAULT_HOST.to_string();
|
||||
} else {
|
||||
buster_creds.url = url_input;
|
||||
}
|
||||
}
|
||||
|
||||
if buster_creds.api_key.is_empty() {
|
||||
let obfuscated_api_key = if buster_creds.api_key.is_empty() {
|
||||
String::from("None")
|
||||
} else {
|
||||
format!("{}****", &buster_creds.api_key[..4])
|
||||
};
|
||||
|
||||
let api_key_input = Password::new(&format!("Enter your API key [{obfuscated_api_key}]:"))
|
||||
.without_confirmation()
|
||||
.with_help_message("Your API key can be found in your Buster dashboard")
|
||||
.prompt()
|
||||
.context("Failed to get API key input")?;
|
||||
|
||||
if api_key_input.is_empty() && buster_creds.api_key.is_empty() {
|
||||
return Err(AuthError::MissingApiKey.into());
|
||||
} else if !api_key_input.is_empty() {
|
||||
buster_creds.api_key = api_key_input;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
validate_credentials(&buster_creds.url, &buster_creds.api_key).await?;
|
||||
|
||||
// Save credentials unless --no-save is specified
|
||||
if !args.no_save {
|
||||
set_buster_credentials(buster_creds.clone()).await
|
||||
.context("Failed to save credentials")?;
|
||||
println!("Credentials saved successfully!");
|
||||
}
|
||||
|
||||
println!("Authentication successful!");
|
||||
if args.no_save {
|
||||
println!("Note: Credentials were not saved due to --no-save flag");
|
||||
}
|
||||
buster_creds.url = url_input;
|
||||
|
||||
// Obfuscate the API key for display
|
||||
let obfuscated_api_key = if buster_creds.api_key.is_empty() {
|
||||
String::from("None")
|
||||
} else {
|
||||
format!("{}****", &buster_creds.api_key[..4])
|
||||
};
|
||||
|
||||
let api_key_input = Password::new(&format!("Enter your API key [{obfuscated_api_key}]:"))
|
||||
.without_confirmation()
|
||||
.prompt()?;
|
||||
|
||||
if !api_key_input.is_empty() {
|
||||
buster_creds.api_key = api_key_input;
|
||||
} else if buster_creds.api_key.is_empty() {
|
||||
anyhow::bail!("API key is required");
|
||||
}
|
||||
|
||||
// Validate the API key.
|
||||
let buster_client = BusterClient::new(buster_creds.url.clone(), buster_creds.api_key.clone())?;
|
||||
|
||||
if !buster_client.validate_api_key().await? {
|
||||
anyhow::bail!("Invalid API key");
|
||||
}
|
||||
|
||||
// Save the credentials.
|
||||
set_buster_credentials(buster_creds).await?;
|
||||
|
||||
println!("Authentication successful!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
mod auth;
|
||||
pub mod auth;
|
||||
mod deploy;
|
||||
mod deploy_v2;
|
||||
mod generate;
|
||||
mod import;
|
||||
mod init;
|
||||
pub mod version;
|
||||
pub mod update;
|
||||
|
||||
pub use auth::auth;
|
||||
pub use auth::{auth, auth_with_args, AuthArgs};
|
||||
pub use deploy::deploy;
|
||||
pub use deploy_v2::deploy_v2;
|
||||
pub use generate::GenerateCommand;
|
||||
pub use import::import;
|
||||
pub use init::init;
|
||||
pub use update::UpdateCommand;
|
||||
|
|
|
@ -0,0 +1,263 @@
|
|||
use anyhow::{Result, Context};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::fs;
|
||||
use std::env;
|
||||
use reqwest::Client;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use colored::*;
|
||||
use futures_util::StreamExt;
|
||||
use inquire::Confirm;
|
||||
use std::io::Write;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const GITHUB_RELEASES_URL: &str = "https://github.com/buster-so/buster/releases/download";
|
||||
|
||||
pub struct UpdateCommand {
|
||||
check_only: bool,
|
||||
force: bool,
|
||||
no_prompt: bool,
|
||||
}
|
||||
|
||||
impl UpdateCommand {
|
||||
pub fn new(check_only: bool, force: bool, no_prompt: bool) -> Self {
|
||||
Self {
|
||||
check_only,
|
||||
force,
|
||||
no_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
// Check current version and latest version
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let latest_version = super::version::check_latest_version()
|
||||
.await?
|
||||
.context("Failed to get latest version")?;
|
||||
|
||||
println!("Current version: {}", current_version);
|
||||
println!("Latest version: {}", latest_version);
|
||||
|
||||
let update_available = super::version::is_update_available(current_version, &latest_version);
|
||||
|
||||
if !update_available && !self.force {
|
||||
println!("\n{}", "You are using the latest version".green());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.check_only {
|
||||
if update_available {
|
||||
println!("\n{}", "Update available!".yellow().bold());
|
||||
println!("Run {} to update", "buster update".cyan());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.no_prompt {
|
||||
let confirm = Confirm::new("Do you want to update to the latest version?")
|
||||
.with_default(true)
|
||||
.with_help_message("This will replace your current binary with the latest version")
|
||||
.prompt();
|
||||
|
||||
match confirm {
|
||||
Ok(true) => (),
|
||||
Ok(false) => {
|
||||
println!("Update cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(anyhow::anyhow!("Update cancelled due to input error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.perform_update(&latest_version).await
|
||||
}
|
||||
|
||||
async fn perform_update(&self, version: &str) -> Result<()> {
|
||||
let package_name = self.get_package_name()?;
|
||||
let download_url = format!("{}/{}/{}", GITHUB_RELEASES_URL, version, package_name);
|
||||
let checksum_url = format!("{}.sha256", download_url);
|
||||
|
||||
if env::var("BUSTER_DEBUG").is_ok() {
|
||||
println!("Debug: Download URL: {}", download_url);
|
||||
println!("Debug: Checksum URL: {}", checksum_url);
|
||||
}
|
||||
|
||||
println!("\nDownloading update...");
|
||||
|
||||
// Create temporary directory
|
||||
let temp_dir = tempfile::Builder::new()
|
||||
.prefix("buster-update-")
|
||||
.tempdir()?;
|
||||
|
||||
// Download package
|
||||
let package_path = temp_dir.path().join(&package_name);
|
||||
match self.download_file(&download_url, &package_path).await {
|
||||
Ok(_) => println!("✓ Package downloaded successfully"),
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to download package: {}\nURL: {}", e, download_url)),
|
||||
}
|
||||
|
||||
// Download and verify checksum
|
||||
let checksum_path = temp_dir.path().join(format!("{}.sha256", package_name));
|
||||
match self.download_file(&checksum_url, &checksum_path).await {
|
||||
Ok(_) => println!("✓ Checksum file downloaded"),
|
||||
Err(e) => {
|
||||
if env::var("BUSTER_DEV").is_ok() {
|
||||
println!("Skipping checksum verification in development mode");
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Failed to download checksum: {}\nURL: {}", e, checksum_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current binary path
|
||||
let current_binary = env::current_exe()?;
|
||||
let backup_path = current_binary.with_extension("bak");
|
||||
|
||||
// Create backup
|
||||
println!("Creating backup...");
|
||||
fs::copy(¤t_binary, &backup_path)?;
|
||||
|
||||
// Extract and replace binary
|
||||
println!("Installing update...");
|
||||
self.replace_binary(&package_path, ¤t_binary)?;
|
||||
|
||||
// Verify new binary
|
||||
if self.verify_new_binary(¤t_binary)? {
|
||||
fs::remove_file(backup_path)?;
|
||||
println!("\n{}", "✓ Successfully updated!".green().bold());
|
||||
println!("Version: {}", version);
|
||||
} else {
|
||||
println!("\n{}", "✗ Update verification failed, rolling back...".red());
|
||||
fs::copy(&backup_path, ¤t_binary)?;
|
||||
return Err(anyhow::anyhow!("Failed to verify new binary"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_package_name(&self) -> Result<String> {
|
||||
let os = env::consts::OS;
|
||||
let arch = env::consts::ARCH;
|
||||
|
||||
let package_name = match (os, arch) {
|
||||
("linux", "x86_64") => "buster-cli-linux-x86_64.tar.gz",
|
||||
("macos", "x86_64") => "buster-cli-darwin-x86_64.tar.gz",
|
||||
("macos", "aarch64") => "buster-cli-darwin-arm64.tar.gz",
|
||||
("windows", "x86_64") => "buster-cli-windows-x86_64.zip",
|
||||
_ => return Err(anyhow::anyhow!("Unsupported platform: {}-{}", os, arch)),
|
||||
};
|
||||
|
||||
Ok(package_name.to_string())
|
||||
}
|
||||
|
||||
async fn download_file(&self, url: &str, path: &Path) -> Result<()> {
|
||||
let client = Client::new();
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "buster-cli")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to download file: HTTP {}\nURL: {}",
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("[{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let mut file = fs::File::create(path)?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
pb.inc(chunk.len() as u64);
|
||||
file.write_all(&chunk)?;
|
||||
}
|
||||
|
||||
pb.finish_and_clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_checksum(&self, package_path: &Path, checksum_path: &Path) -> Result<()> {
|
||||
// For local development, skip checksum verification if env var is set
|
||||
if env::var("BUSTER_DEV").is_ok() {
|
||||
println!("Skipping checksum verification in development mode");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let expected_checksum = fs::read_to_string(checksum_path)?;
|
||||
let expected_checksum = expected_checksum.split_whitespace().next()
|
||||
.context("Invalid checksum file format")?;
|
||||
|
||||
let output = Command::new("shasum")
|
||||
.arg("-a")
|
||||
.arg("256")
|
||||
.arg(package_path)
|
||||
.output()?;
|
||||
|
||||
let actual_checksum = String::from_utf8(output.stdout)?;
|
||||
let actual_checksum = actual_checksum.split_whitespace().next()
|
||||
.context("Failed to compute checksum")?;
|
||||
|
||||
if actual_checksum != expected_checksum {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Checksum verification failed\nExpected: {}\nGot: {}",
|
||||
expected_checksum,
|
||||
actual_checksum
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_binary(&self, package_path: &Path, target_path: &Path) -> Result<()> {
|
||||
// Extract the package based on its extension
|
||||
if package_path.extension().and_then(|s| s.to_str()) == Some("zip") {
|
||||
// Handle ZIP files (Windows)
|
||||
let file = fs::File::open(package_path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
// Extract the binary
|
||||
let mut binary = archive.by_name("buster-cli.exe")?;
|
||||
let mut temp_path = package_path.parent().unwrap().join("buster-cli.exe");
|
||||
let mut outfile = fs::File::create(&temp_path)?;
|
||||
std::io::copy(&mut binary, &mut outfile)?;
|
||||
|
||||
// Move to target location
|
||||
fs::rename(temp_path, target_path)?;
|
||||
} else {
|
||||
// Handle tar.gz files (Unix)
|
||||
Command::new("tar")
|
||||
.arg("xzf")
|
||||
.arg(package_path)
|
||||
.current_dir(package_path.parent().unwrap())
|
||||
.output()?;
|
||||
|
||||
// Move the extracted binary to the target location
|
||||
let extracted_binary = package_path.parent().unwrap().join("buster-cli");
|
||||
fs::rename(extracted_binary, target_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_new_binary(&self, binary_path: &Path) -> Result<bool> {
|
||||
let output = Command::new(binary_path)
|
||||
.arg("version")
|
||||
.output()?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use dirs::cache_dir;
|
||||
|
||||
const GITHUB_API_URL: &str = "https://api.github.com/repos/buster-so/buster/releases/latest";
|
||||
const CACHE_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubRelease {
|
||||
tag_name: String,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct VersionCache {
|
||||
version: String,
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
pub async fn check_latest_version() -> Result<Option<String>> {
|
||||
if let Some(cached_version) = get_cached_version()? {
|
||||
return Ok(Some(cached_version));
|
||||
}
|
||||
|
||||
let client = Client::new();
|
||||
let response = client
|
||||
.get(GITHUB_API_URL)
|
||||
.header("User-Agent", "buster-cli")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let release: GitHubRelease = response.json().await?;
|
||||
cache_version(&release.tag_name)?;
|
||||
|
||||
Ok(Some(release.tag_name))
|
||||
}
|
||||
|
||||
fn get_cache_path() -> Option<PathBuf> {
|
||||
let mut cache_path = cache_dir()?;
|
||||
cache_path.push("buster");
|
||||
cache_path.push("version_check.json");
|
||||
Some(cache_path)
|
||||
}
|
||||
|
||||
fn get_cached_version() -> Result<Option<String>> {
|
||||
let cache_path = match get_cache_path() {
|
||||
Some(path) => path,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if !cache_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let cache_content = fs::read_to_string(cache_path)?;
|
||||
let cached: VersionCache = serde_json::from_str(&cache_content)?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
|
||||
if now - cached.timestamp > CACHE_TTL.as_secs() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(cached.version))
|
||||
}
|
||||
|
||||
fn cache_version(version: &str) -> Result<()> {
|
||||
let cache_path = match get_cache_path() {
|
||||
Some(path) => path,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let cache = VersionCache {
|
||||
version: version.to_string(),
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)?
|
||||
.as_secs(),
|
||||
};
|
||||
|
||||
let cache_content = serde_json::to_string(&cache)?;
|
||||
fs::write(cache_path, cache_content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_update_available(current: &str, latest: &str) -> bool {
|
||||
// Strip 'v' prefix if present
|
||||
let current = current.trim_start_matches('v');
|
||||
let latest = latest.trim_start_matches('v');
|
||||
|
||||
// Split into version components
|
||||
let current_parts: Vec<&str> = current.split('.').collect();
|
||||
let latest_parts: Vec<&str> = latest.split('.').collect();
|
||||
|
||||
// Compare version components
|
||||
for (c, l) in current_parts.iter().zip(latest_parts.iter()) {
|
||||
let c_num: u32 = c.parse().unwrap_or(0);
|
||||
let l_num: u32 = l.parse().unwrap_or(0);
|
||||
if l_num > c_num {
|
||||
return true;
|
||||
}
|
||||
if c_num > l_num {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here and latest has more components, it's newer
|
||||
latest_parts.len() > current_parts.len()
|
||||
}
|
|
@ -4,16 +4,47 @@ mod types;
|
|||
mod utils;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use commands::{auth, deploy, deploy_v2, GenerateCommand, import, init};
|
||||
use colored::*;
|
||||
use commands::{auth::AuthArgs, deploy, deploy_v2, import, init, GenerateCommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const APP_NAME: &str = "buster";
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const BUILD_DATE: &str = env!("BUILD_DATE");
|
||||
pub const GIT_HASH: &str = env!("GIT_HASH");
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[clap(rename_all = "kebab-case")]
|
||||
pub enum Commands {
|
||||
Init,
|
||||
Auth,
|
||||
/// Authenticate with Buster API
|
||||
Auth {
|
||||
/// The Buster API host URL
|
||||
#[arg(long, env = "BUSTER_HOST")]
|
||||
host: Option<String>,
|
||||
|
||||
/// Your Buster API key
|
||||
#[arg(long, env = "BUSTER_API_KEY")]
|
||||
api_key: Option<String>,
|
||||
|
||||
/// Don't save credentials to disk
|
||||
#[arg(long)]
|
||||
no_save: bool,
|
||||
},
|
||||
/// Display version information
|
||||
Version,
|
||||
/// Update buster-cli to the latest version
|
||||
Update {
|
||||
/// Only check if an update is available
|
||||
#[arg(long)]
|
||||
check_only: bool,
|
||||
/// Force update even if already on latest version
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Skip update confirmation prompt
|
||||
#[arg(short = 'y')]
|
||||
no_prompt: bool,
|
||||
},
|
||||
Generate {
|
||||
#[arg(long)]
|
||||
source_path: Option<String>,
|
||||
|
@ -48,19 +79,63 @@ async fn main() {
|
|||
// TODO: All commands should check for an update.
|
||||
let result = match args.cmd {
|
||||
Commands::Init => init().await,
|
||||
Commands::Auth => auth().await,
|
||||
Commands::Generate {
|
||||
Commands::Auth {
|
||||
host,
|
||||
api_key,
|
||||
no_save,
|
||||
} => {
|
||||
commands::auth::auth_with_args(AuthArgs {
|
||||
host,
|
||||
api_key,
|
||||
no_save,
|
||||
})
|
||||
.await
|
||||
}
|
||||
Commands::Version => {
|
||||
println!("{} v{}", APP_NAME.bold(), VERSION);
|
||||
println!("Build Date: {}", BUILD_DATE);
|
||||
println!("Git Commit: {}", GIT_HASH);
|
||||
|
||||
// Check for updates
|
||||
match commands::version::check_latest_version().await {
|
||||
Ok(Some(latest_version)) => {
|
||||
if commands::version::is_update_available(VERSION, &latest_version) {
|
||||
println!("\n{}", "Update available!".yellow().bold());
|
||||
println!("Latest version: {}", latest_version.green());
|
||||
println!("Run {} to update", "buster update".cyan());
|
||||
} else {
|
||||
println!("\n{}", "You are using the latest version".green());
|
||||
}
|
||||
}
|
||||
Ok(None) => println!("\n{}", "Unable to check for updates".yellow()),
|
||||
Err(e) => println!("\n{}: {}", "Error checking for updates".red(), e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Commands::Update {
|
||||
check_only,
|
||||
force,
|
||||
no_prompt,
|
||||
} => {
|
||||
let cmd = commands::update::UpdateCommand::new(check_only, force, no_prompt);
|
||||
cmd.execute().await
|
||||
}
|
||||
Commands::Generate {
|
||||
source_path,
|
||||
destination_path,
|
||||
data_source_name,
|
||||
schema,
|
||||
database,
|
||||
} => {
|
||||
let source = source_path.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
let dest = destination_path.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
let source = source_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let dest = destination_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let cmd = GenerateCommand::new(source, dest, data_source_name, schema, database);
|
||||
cmd.execute().await
|
||||
},
|
||||
}
|
||||
Commands::Import => import().await,
|
||||
Commands::Deploy { path, dry_run } => deploy_v2(path.as_deref(), dry_run).await,
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ use tokio::fs;
|
|||
|
||||
use crate::{error::BusterError, utils::BusterClient};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct BusterCredentials {
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
## Buster Warehouse Overview
|
||||
#p# Buster Warehouse Overview
|
||||
|
||||
In working with our customers, we found that Snowflake, Bigquery, and other warehouse solutions were prohibitively expensive or slow in them being able to deploy AI-powered analytics at scale.
|
||||
|
||||
|
|
Loading…
Reference in New Issue