Add envsync-cli commands + tested AUTH with keycloak

Adds source modules for api (auth, vault, mod), cli entrypoints, command implementations (diff, init, log, pull, push, rollback, set, unset), plus config, crypto and error support. main.rs was updated to wire up the new CLI. Cargo.toml updated to declare required dependencies and Cargo.lock regenerated.

/api/auth
is able to auth against keycloak with device codes and retriving a accessToken to access the spring boot backend.
This commit is contained in:
Aryan Singh committed 2026-02-25 16:18:24 -06:00
1 parent f32e5bfea2
commit 3d72c53262
20 files changed
+2637 -35

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
envsync-cli/target
.idea
+2392 -31
View File
File diff suppressed because it is too large. Load diff
+20 -2
View File
@@ -4,5 +4,23 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.5.60", features = ["derive"] } anyhow = "1.0.102"
tokio = { version = "1.49.0", features = ["full"] } clap = "4.5.60"
comfy-table = "7.2.2"
console = "0.16.2"
ctrlc = "3.5.2"
dialoguer = "0.12.0"
directories = "6.0.0"
dotenvy = "0.15.7"
indicatif = "0.18.4"
reqwest = { version = "0.13.2", features = ["form", "json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sodiumoxide = "0.2.7"
thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.22"
url = "2.5.8"
chrono = "0.4.44"
toml = "1.0.3"
+78
View File
@@ -0,0 +1,78 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::sleep;
#[derive(Debug, Serialize)]
struct DeviceAuthRequest {
client_id: String,
client_secret: String,
}
#[derive(Debug, Deserialize)]
struct DeviceAuthResponse {
device_code: String,
user_code: String,
verification_uri: String,
interval: u64,
expires_in: u64,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: u64,
}
pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result<String> {
// 1. Request device code
let device_auth_url = format!("{}/protocol/openid-connect/auth/device", keycloak_url);
let params = [
("client_id", client_id),
];
let response = client.post(&device_auth_url).form(&params).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Device auth failed ({}): {}", status, body);
}
let resp: DeviceAuthResponse = response.json().await?;
println!("Open this URL in your browser: {}", resp.verification_uri);
println!("Enter the code: {}", resp.user_code);
// 2. Poll for token
let token_url = format!("{}/protocol/openid-connect/token", keycloak_url);
let interval = Duration::from_secs(resp.interval);
let start = std::time::Instant::now();
while start.elapsed().as_secs() < resp.expires_in {
let params = [
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", &resp.device_code),
("client_id", client_id),
];
let token_resp = client.post(&token_url).form(&params).send().await?;
let status = token_resp.status();
let text = token_resp.text().await.unwrap_or_default();
println!("Token response status: {}, body: {}", status, text);
if status.is_success() {
let token: TokenResponse = serde_json::from_str(&text)?;
return Ok(token.access_token);
} else if status.as_u16() == 400 && text.contains("authorization_pending") {
sleep(interval).await;
} else {
anyhow::bail!("Unexpected token response: {} - {}", status, text);
}
}
anyhow::bail!("Authentication timed out");
}
+1
View File
@@ -0,0 +1 @@
pub mod auth;
View File
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
+118
View File
@@ -0,0 +1,118 @@
use anyhow::{Result, Context, anyhow};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use sodiumoxide::crypto::box__;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AuthConfig {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub user_id: String,
pub user_email: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct KeypairConfig {
pub public_key: String, // base64 encoded
pub private_key: String, // base64 encrypted with a passphrase?
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VaultContext {
pub project_id: String,
pub project_name: String,
pub environment: String,
pub vault_key: Option<String>, // base64 encrypted with user's public key
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
pub api_url: String,
pub auth: Option<AuthConfig>,
pub keypair: Option<KeypairConfig>,
pub current_context: Option<VaultContext>,
}
impl Config {
pub fn path() -> PathBuf {
ProjectDirs::from("com", "envsync", "cli")
.expect("Failed to get config directory")
.config_dir()
.join("config.toml")
}
pub fn load() -> Result<Self> {
let path = Self::path();
if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {:?}", path))?;
Ok(toml::from_str(&content)?)
} else {
Ok(Config::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
pub fn get_auth_token(&self) -> Result<String> {
self.auth
.as_ref()
.map(|a| a.access_token.clone())
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync init' first."))
}
pub fn get_current_context(&self) -> Result<&VaultContext> {
self.current_context
.as_ref()
.ok_or_else(|| anyhow!("No project initialized. Run 'envsync init' in your project."))
}
pub fn set_current_context(&mut self, context: VaultContext) {
self.current_context = Some(context);
}
}
#[derive(Debug, Clone)]
pub struct ProjectConfig {
pub project_name: String,
pub environment: String,
pub env_file_path: PathBuf,
}
impl ProjectConfig {
pub fn from_current_dir(project_name: Option<String>, environment: Option<String>) -> Result<Self> {
let current_dir = std::env::current_dir()?;
// Look for existing .env file
let env_file_path = current_dir.join(".env");
// If project name not provided, use directory name
let project_name = match project_name {
Some(name) => name,
None => current_dir
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("Could not determine project name from directory"))?
.to_string(),
};
let environment = environment.unwrap_or_else(|| "development".to_string());
Ok(ProjectConfig {
project_name,
environment,
env_file_path,
})
}
}
View File
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+26 -2
View File
@@ -1,3 +1,27 @@
fn main() { // main.rs
println!("Hello, world!"); use anyhow::Result;
use reqwest::Client;
// Import your authenticate function from the module where you defined it
mod api;
use api::auth::authenticate;
#[tokio::main]
async fn main() -> Result<()> {
let keycloak_url = std::env::var("KEYCLOAK_URL")
.unwrap_or_else(|_| "http://localhost:8180/realms/envsync".to_string());
let client_id = std::env::var("KEYCLOAK_CLIENT_ID")
.unwrap_or_else(|_| "envsync-app-cli".to_string());
// Create an HTTP client
let client = Client::new();
// Call the authentication function and also need to wait fot the user to complete the authentication in the browser
let token = authenticate(&client, &keycloak_url, &client_id).await?;
println!("Access Token: {}", token);
Ok(())
} }