Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
721 changes: 712 additions & 9 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ sigilforge-core = { path = "sigilforge-core" }

# Async runtime
tokio = { version = "1.41", features = ["full"] }
tokio-stream = { version = "0.1", features = ["net"] }

# Serialization
serde = { version = "1.0", features = ["derive"] }
Expand Down Expand Up @@ -59,3 +60,6 @@ chrono = { version = "0.4", features = ["serde"] }

# Async traits
async-trait = "0.1"

# JSON-RPC
jsonrpsee = { version = "0.24", features = ["server", "client", "macros"] }
3 changes: 3 additions & 0 deletions sigilforge-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ tracing-subscriber = { workspace = true }

# Configuration
directories = { workspace = true }

# JSON-RPC client
jsonrpsee = { workspace = true }
194 changes: 194 additions & 0 deletions sigilforge-cli/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! Daemon client for communicating with sigilforged.
//!
//! This module provides a client for connecting to the Sigilforge daemon
//! over a Unix socket (or named pipe on Windows) using JSON-RPC.

use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tracing::{debug, warn};

/// Response containing a fresh access token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetTokenResponse {
pub token: String,
pub expires_at: Option<String>,
}

/// Information about a configured account.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub service: String,
pub account: String,
pub scopes: Vec<String>,
pub created_at: String,
pub last_used: Option<String>,
}

/// Response containing a list of accounts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListAccountsResponse {
pub accounts: Vec<AccountInfo>,
}

/// Response after adding an account.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddAccountResponse {
pub message: String,
}

/// Response containing a resolved value.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolveResponse {
pub value: String,
}

/// Client for communicating with the Sigilforge daemon.
pub struct DaemonClient {
stream: Option<UnixStream>,
socket_path: PathBuf,
next_id: u64,
}

impl DaemonClient {
/// Attempt to connect to the daemon at the given socket path.
pub async fn connect(socket_path: &Path) -> Result<Self> {
debug!("Attempting to connect to daemon at {:?}", socket_path);

// Check if socket exists
if !socket_path.exists() {
debug!("Socket does not exist at {:?}", socket_path);
return Ok(Self {
stream: None,
socket_path: socket_path.to_path_buf(),
next_id: 1,
});
}

// Try to connect to the Unix socket
match UnixStream::connect(socket_path).await {
Ok(stream) => {
debug!("Successfully connected to daemon");
Ok(Self {
stream: Some(stream),
socket_path: socket_path.to_path_buf(),
next_id: 1,
})
}
Err(e) => {
warn!("Failed to connect to daemon: {}", e);
Ok(Self {
stream: None,
socket_path: socket_path.to_path_buf(),
next_id: 1,
})
}
}
}

/// Connect to daemon using default socket path.
pub async fn connect_default() -> Result<Self> {
let socket_path = default_socket_path();
Self::connect(&socket_path).await
}

/// Check if the client is connected to the daemon.
pub fn is_connected(&self) -> bool {
self.stream.is_some()
}

/// Send a JSON-RPC request and receive a response.
async fn send_request<T: for<'de> Deserialize<'de>>(
&mut self,
method: &str,
params: serde_json::Value,
) -> Result<T> {
let stream = self
.stream
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Not connected to daemon"))?;

let id = self.next_id;
self.next_id += 1;

let request = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": id,
});

let request_str = serde_json::to_string(&request)?;
debug!("Sending request: {}", request_str);

stream.write_all(request_str.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.flush().await?;

let mut reader = BufReader::new(stream);
let mut response_str = String::new();
reader.read_line(&mut response_str).await?;

debug!("Received response: {}", response_str);

let response: serde_json::Value = serde_json::from_str(&response_str)?;

if let Some(error) = response.get("error") {
anyhow::bail!("RPC error: {}", error);
}

let result = response
.get("result")
.ok_or_else(|| anyhow::anyhow!("No result in response"))?;

Ok(serde_json::from_value(result.clone())?)
}

/// Get a fresh access token for the specified account.
pub async fn get_token(&mut self, service: &str, account: &str) -> Result<GetTokenResponse> {
self.send_request("get_token", json!([service, account]))
.await
}

/// List all configured accounts, optionally filtered by service.
pub async fn list_accounts(
&mut self,
service: Option<&str>,
) -> Result<ListAccountsResponse> {
self.send_request("list_accounts", json!([service]))
.await
}

/// Add a new account with the specified scopes.
pub async fn add_account(
&mut self,
service: &str,
account: &str,
scopes: Vec<String>,
) -> Result<AddAccountResponse> {
self.send_request("add_account", json!([service, account, scopes]))
.await
}

/// Resolve a credential reference to its actual value.
pub async fn resolve(&mut self, reference: &str) -> Result<ResolveResponse> {
self.send_request("resolve", json!([reference])).await
}
}

/// Get the default socket path for the daemon.
pub fn default_socket_path() -> PathBuf {
let dirs = ProjectDirs::from("com", "raibid-labs", "sigilforge");

if cfg!(unix) {
dirs.as_ref()
.map(|d| d.runtime_dir().unwrap_or(d.data_dir()).join("sigilforge.sock"))
.unwrap_or_else(|| PathBuf::from("/tmp/sigilforge.sock"))
} else {
PathBuf::from(r"\\.\pipe\sigilforge")
}
}
118 changes: 112 additions & 6 deletions sigilforge-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@

use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing::info;
use tracing::{info, warn};
use tracing_subscriber::FmtSubscriber;

mod client;

#[derive(Parser)]
#[command(name = "sigilforge")]
#[command(about = "Credential management for the raibid-labs ecosystem")]
Expand Down Expand Up @@ -124,6 +126,30 @@ async fn main() -> Result<()> {
}

async fn add_account(service: &str, account: &str, scopes: Option<&str>) -> Result<()> {
let mut client = client::DaemonClient::connect_default().await?;

if client.is_connected() {
let scope_vec = scopes
.map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();

match client.add_account(service, account, scope_vec).await {
Ok(response) => {
println!("{}", response.message);
Ok(())
}
Err(e) => {
warn!("Daemon call failed: {}", e);
fallback_add_account(service, account, scopes).await
}
}
} else {
warn!("Daemon not available, using fallback mode");
fallback_add_account(service, account, scopes).await
}
}

async fn fallback_add_account(service: &str, account: &str, scopes: Option<&str>) -> Result<()> {
use sigilforge_core::{Account, AccountId, AccountStore, ServiceId};

let store = AccountStore::load()?;
Expand Down Expand Up @@ -152,6 +178,38 @@ async fn add_account(service: &str, account: &str, scopes: Option<&str>) -> Resu
}

async fn list_accounts(service_filter: Option<&str>) -> Result<()> {
let mut client = client::DaemonClient::connect_default().await?;

if client.is_connected() {
match client.list_accounts(service_filter).await {
Ok(response) => {
if response.accounts.is_empty() {
println!("No accounts configured");
} else {
println!("Configured accounts:");
for account in response.accounts {
println!(" {}/{}", account.service, account.account);
println!(" Scopes: {}", account.scopes.join(", "));
println!(" Created: {}", account.created_at);
if let Some(last_used) = account.last_used {
println!(" Last used: {}", last_used);
}
}
}
Ok(())
}
Err(e) => {
warn!("Daemon call failed: {}", e);
fallback_list_accounts(service_filter).await
}
}
} else {
warn!("Daemon not available, using fallback mode");
fallback_list_accounts(service_filter).await
}
}

async fn fallback_list_accounts(service_filter: Option<&str>) -> Result<()> {
use sigilforge_core::{AccountStore, ServiceId};

let store = AccountStore::load()?;
Expand All @@ -168,11 +226,6 @@ async fn list_accounts(service_filter: Option<&str>) -> Result<()> {
}

println!("Configured accounts:");
if let Some(service) = service_filter {
println!(" (filtered by service: {})", service);
}
println!();

for account in accounts {
println!(" {}/{}", account.service, account.id);
if !account.scopes.is_empty() {
Expand All @@ -188,6 +241,39 @@ async fn list_accounts(service_filter: Option<&str>) -> Result<()> {
}

async fn get_token(service: &str, account: &str, format: &str) -> Result<()> {
let mut client = client::DaemonClient::connect_default().await?;

if client.is_connected() {
match client.get_token(service, account).await {
Ok(response) => {
match format {
"json" => {
let json_output = serde_json::json!({
"service": service,
"account": account,
"token": response.token,
"expires_at": response.expires_at,
});
println!("{}", serde_json::to_string_pretty(&json_output)?);
}
_ => {
println!("{}", response.token);
}
}
Ok(())
}
Err(e) => {
warn!("Daemon call failed: {}", e);
fallback_get_token(service, account, format).await
}
}
} else {
warn!("Daemon not available, using fallback mode");
fallback_get_token(service, account, format).await
}
}

async fn fallback_get_token(service: &str, account: &str, format: &str) -> Result<()> {
println!("[stub] Getting token for {}/{}", service, account);

match format {
Expand Down Expand Up @@ -267,6 +353,26 @@ async fn remove_account(service: &str, account: &str, force: bool) -> Result<()>
}

async fn resolve_reference(reference: &str) -> Result<()> {
let mut client = client::DaemonClient::connect_default().await?;

if client.is_connected() {
match client.resolve(reference).await {
Ok(response) => {
println!("{}", response.value);
Ok(())
}
Err(e) => {
warn!("Daemon call failed: {}", e);
fallback_resolve_reference(reference).await
}
}
} else {
warn!("Daemon not available, using fallback mode");
fallback_resolve_reference(reference).await
}
}

async fn fallback_resolve_reference(reference: &str) -> Result<()> {
use sigilforge_core::CredentialRef;

match CredentialRef::from_auth_uri(reference) {
Expand Down
4 changes: 3 additions & 1 deletion sigilforge-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ keyring = { workspace = true, optional = true }
# OAuth2
oauth2 = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
rand = { version = "0.8", optional = true }

[features]
default = ["keyring-store"]
keyring-store = ["dep:keyring"]
oauth = ["dep:oauth2", "dep:reqwest"]
oauth = ["dep:oauth2", "dep:reqwest", "dep:rand"]
full = ["keyring-store", "oauth"]

[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3.13"
wiremock = "0.6"
Loading
Loading