use anyhow::{bail, Context, Result};
use reqwest::blocking::Client;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::collections::HashMap;
use colored::*;
use crate::types::{
ApiTokenRow, ImplementationRequest, RegistryPackage, ResolveArtifactsResponse, UserProfile,
};
fn local_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
let h = s.trim();
if !h.is_empty() {
return h.to_string();
}
}
if let Ok(out) = std::process::Command::new("hostname").output() {
if out.status.success() {
let h = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !h.is_empty() {
return h;
}
}
}
"device".to_string()
}
pub fn default_cli_token_name() -> String {
format!("xsil-cli @ {}", local_hostname())
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
struct Config {
registry: Option<String>,
token: Option<String>,
}
fn config_path() -> PathBuf {
let home = directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
home.join(crate::constants::CONFIG_RELATIVE_PATH)
}
fn load_config() -> Config {
let path = config_path();
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(cfg) = serde_json::from_str::<Config>(&content) {
return cfg;
}
}
}
Config::default()
}
fn save_config(cfg: &Config) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_string_pretty(cfg)?)?;
Ok(())
}
pub struct RegistryClient {
base_url: String,
client: Client,
}
impl RegistryClient {
pub fn new(base_url: &str) -> Self {
let client = Client::builder()
.no_gzip()
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url: base_url.to_string(),
client,
}
}
pub fn from_config() -> Self {
let cfg = load_config();
let url = cfg.registry
.or_else(|| std::env::var("XSIL_REGISTRY").ok())
.unwrap_or_else(|| crate::constants::DEFAULT_REGISTRY.to_string());
Self::new(&url)
}
fn load_token(&self) -> Option<String> {
load_config().token
}
fn auth_header(&self) -> Option<String> {
self.load_token().map(|t| format!("Bearer {}", t))
}
fn dependency_key(name: &str, version: &str, platform: &str, sha256: &str) -> String {
let sha = sha256
.trim()
.trim_start_matches("sha256:")
.trim_start_matches("sha256-")
.to_ascii_lowercase();
format!("{}::{}::{}::{}", name.trim(), version.trim(), platform.trim(), sha)
}
pub fn login(&self, name_override: Option<&str>) -> Result<()> {
print!("Email: ");
std::io::stdout().flush()?;
let mut email = String::new();
std::io::stdin().read_line(&mut email)?;
let email = email.trim().to_string();
print!("Password: ");
std::io::stdout().flush()?;
let password = rpassword::read_password().context("Failed to read password")?;
let token_name = name_override
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(default_cli_token_name);
let body = serde_json::json!({
"email": email,
"password": password,
"name": token_name,
});
let resp = self
.client
.post(format!("{}/auth/login", self.base_url))
.json(&body)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response from registry")?;
if !status.is_success() {
bail!(
"Login failed: {}",
json.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error")
);
}
let token = json
.get("token")
.and_then(|v| v.as_str())
.context("No token in login response")?
.to_string();
let username = json
.pointer("/user/username")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
let mut cfg = load_config();
cfg.token = Some(token);
save_config(&cfg)?;
println!(
"{} Logged in as {} (token: {}).",
"✔".green(),
username.bold(),
token_name.dimmed()
);
Ok(())
}
pub fn logout(&self) -> Result<()> {
let token = self
.load_token()
.context("Not logged in. Nothing to do.")?;
let resp = self
.client
.post(format!("{}/auth/logout", self.base_url))
.header("Authorization", format!("Bearer {}", token))
.send()
.context("Failed to reach the registry")?;
if !resp.status().is_success() {
eprintln!("{} Registry returned {}; clearing token anyway.", "!".yellow(), resp.status());
}
let mut cfg = load_config();
cfg.token = None;
save_config(&cfg)?;
println!("{} Logged out.", "✔".green());
Ok(())
}
pub fn list_tokens(&self) -> Result<Vec<ApiTokenRow>> {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first.")?;
let resp = self
.client
.get(format!("{}/auth/me/tokens", self.base_url))
.header("Authorization", auth)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response")?;
if !status.is_success() {
bail!(
"{}",
json.get("error").and_then(|v| v.as_str()).unwrap_or("Failed to list tokens")
);
}
let tokens = json
.get("tokens")
.cloned()
.context("Missing `tokens` array in response")?;
let rows: Vec<ApiTokenRow> =
serde_json::from_value(tokens).context("Failed to parse tokens array")?;
Ok(rows)
}
pub fn create_token(&self, name: &str) -> Result<(String, ApiTokenRow)> {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first.")?;
let body = serde_json::json!({ "name": name });
let resp = self
.client
.post(format!("{}/auth/me/tokens", self.base_url))
.header("Authorization", auth)
.json(&body)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response")?;
if !status.is_success() {
bail!(
"{}",
json.get("error").and_then(|v| v.as_str()).unwrap_or("Failed to create token")
);
}
let raw = json
.get("token")
.and_then(|v| v.as_str())
.context("Missing `token` (raw) in response")?
.to_string();
let row: ApiTokenRow = serde_json::from_value(
json.get("apiToken").cloned().context("Missing `apiToken` in response")?,
)
.context("Failed to parse apiToken row")?;
Ok((raw, row))
}
pub fn revoke_token(&self, id: u32) -> Result<bool> {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first.")?;
let resp = self
.client
.delete(format!("{}/auth/me/tokens/{}", self.base_url, id))
.header("Authorization", auth)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().unwrap_or(serde_json::Value::Null);
if !status.is_success() {
bail!(
"{}",
json.get("error").and_then(|v| v.as_str()).unwrap_or("Failed to revoke token")
);
}
Ok(json
.get("alreadyRevoked")
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub fn whoami(&self) -> Result<UserProfile> {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first.")?;
let resp = self
.client
.get(format!("{}/auth/me", self.base_url))
.header("Authorization", auth)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response")?;
if !status.is_success() {
bail!(
"{}",
json.get("error").and_then(|v| v.as_str()).unwrap_or("Not authenticated")
);
}
let user: UserProfile = serde_json::from_value(
json.get("user").cloned().unwrap_or(json),
)
.context("Failed to parse user profile")?;
Ok(user)
}
fn registry_error(json: &serde_json::Value, fallback: &str) -> String {
json.get("error")
.and_then(|v| v.as_str())
.unwrap_or(fallback)
.to_string()
}
fn registry_json(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
require_auth: bool,
) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.request(method, url);
if require_auth {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first.")?;
req = req.header("Authorization", auth);
} else if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response from registry")?;
if !status.is_success() {
bail!("{}", Self::registry_error(&json, "Registry request failed"));
}
Ok(json)
}
fn authed_json(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
self.registry_json(method, path, body, true)
}
pub fn list_implementation_requests(
&self,
status: Option<&str>,
capability: Option<&str>,
) -> Result<Vec<ImplementationRequest>> {
let mut path = String::from("/implementation-requests");
let mut qs = Vec::new();
if let Some(s) = status.map(str::trim).filter(|s| !s.is_empty()) {
qs.push(format!("status={}", urlencoding::encode(s)));
}
if let Some(c) = capability.map(str::trim).filter(|s| !s.is_empty()) {
qs.push(format!("capability={}", urlencoding::encode(c)));
}
if !qs.is_empty() {
path.push('?');
path.push_str(&qs.join("&"));
}
let json = self.registry_json(reqwest::Method::GET, &path, None, false)?;
let requests = json
.get("requests")
.cloned()
.context("Missing `requests` in response")?;
serde_json::from_value(requests).context("Failed to parse implementation requests")
}
pub fn list_package_implementation_requests(
&self,
slug: &str,
) -> Result<Vec<ImplementationRequest>> {
let path = format!("/packages/{}/implementation-requests", slug);
let json = self.registry_json(reqwest::Method::GET, &path, None, false)?;
let requests = json
.get("requests")
.cloned()
.context("Missing `requests` in response")?;
serde_json::from_value(requests).context("Failed to parse implementation requests")
}
pub fn get_implementation_request(&self, id: u32) -> Result<ImplementationRequest> {
let path = format!("/implementation-requests/{id}");
let json = self.registry_json(reqwest::Method::GET, &path, None, false)?;
let request = json
.get("request")
.cloned()
.context("Missing `request` in response")?;
serde_json::from_value(request).context("Failed to parse implementation request")
}
pub fn create_implementation_request(
&self,
slug: &str,
body: &serde_json::Value,
) -> Result<ImplementationRequest> {
let path = format!("/packages/{slug}/implementation-requests");
let json = self.authed_json(reqwest::Method::POST, &path, Some(body))?;
let request = json
.get("request")
.cloned()
.context("Missing `request` in response")?;
serde_json::from_value(request).context("Failed to parse implementation request")
}
pub fn patch_implementation_request(
&self,
id: u32,
body: &serde_json::Value,
) -> Result<ImplementationRequest> {
let path = format!("/implementation-requests/{id}");
let json = self.authed_json(reqwest::Method::PATCH, &path, Some(body))?;
let request = json
.get("request")
.cloned()
.context("Missing `request` in response")?;
serde_json::from_value(request).context("Failed to parse implementation request")
}
pub fn create_implementation_interest(
&self,
id: u32,
body: &serde_json::Value,
) -> Result<()> {
let path = format!("/implementation-requests/{id}/interests");
self.authed_json(reqwest::Method::POST, &path, Some(body))?;
Ok(())
}
pub fn list_my_implementation_requests(&self) -> Result<Vec<ImplementationRequest>> {
let json = self.authed_json(reqwest::Method::GET, "/me/implementation-requests", None)?;
let requests = json
.get("requests")
.cloned()
.context("Missing `requests` in response")?;
serde_json::from_value(requests).context("Failed to parse implementation requests")
}
pub fn publish(
&self,
slug: &str,
version: &str,
changelog: &str,
isa: &str,
targets_json: &str,
toolchain: &str,
keywords_csv: &str,
checksum_payload: &str,
checksum_archive: &str,
size: u64,
xsil_bytes: Vec<u8>,
) -> Result<serde_json::Value> {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first.")?;
let file_part = reqwest::blocking::multipart::Part::bytes(xsil_bytes)
.file_name(format!("{}-{}.xsil", slug, version))
.mime_str("application/octet-stream")?;
let form = reqwest::blocking::multipart::Form::new()
.part("file", file_part)
.text("version", version.to_string())
.text("changelog", changelog.to_string())
.text("isa", isa.to_string())
.text("targets", targets_json.to_string())
.text("toolchain", toolchain.to_string())
.text("keywords", keywords_csv.to_string())
.text("checksumPayload", checksum_payload.to_string())
.text("checksumArchive", checksum_archive.to_string())
.text("size", size.to_string());
let resp = self
.client
.post(format!("{}/packages/{}/versions", self.base_url, slug))
.header("Authorization", auth)
.multipart(form)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response from registry")?;
if !status.is_success() {
bail!(
"Publish failed ({}): {}",
status,
json.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error")
);
}
Ok(json)
}
pub fn search_packages(&self, query: &str) -> Result<Vec<RegistryPackage>> {
let url = if query.trim().is_empty() {
format!("{}/packages", self.base_url)
} else {
format!(
"{}/packages?q={}",
self.base_url,
urlencoding::encode(query)
)
};
let resp = self
.client
.get(&url)
.send()
.context("Failed to connect to registry")?;
if !resp.status().is_success() {
bail!("Search failed: {}", resp.status());
}
let list: Vec<RegistryPackage> =
resp.json().context("Failed to parse search results")?;
Ok(list)
}
pub fn get_package(&self, slug: &str) -> Result<RegistryPackage> {
let url = format!("{}/packages/{}", self.base_url, slug);
let resp = self
.client
.get(&url)
.send()
.context("Failed to connect to registry")?;
if resp.status().as_u16() == 404 {
bail!("Package '{}' not found in the registry.", slug);
}
if !resp.status().is_success() {
bail!("Registry error: {}", resp.status());
}
let pkg: RegistryPackage = resp.json().context("Failed to parse package metadata")?;
Ok(pkg)
}
pub fn yank_version(
&self,
slug: &str,
version: &str,
yanked: bool,
reason: Option<&str>,
) -> Result<serde_json::Value> {
let token = self
.load_token()
.context("Not logged in. Run `xsil login` first.")?;
let url = format!("{}/packages/{}/versions/{}", self.base_url, slug, version);
let mut body = serde_json::json!({ "yanked": yanked });
if let Some(r) = reason {
body["reason"] = serde_json::Value::String(r.to_string());
}
let resp = self
.client
.patch(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.context("Failed to reach the registry")?;
let status = resp.status();
let json: serde_json::Value = resp.json().context("Invalid response from registry")?;
if !status.is_success() {
bail!(
"{}",
json.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown registry error")
);
}
Ok(json)
}
pub fn download_from_url(&self, url: &str) -> Result<Vec<u8>> {
let mut req = self.client.get(url);
if let Some(auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let mut resp = req.send().context("Failed to download file")?;
if !resp.status().is_success() {
bail!("Download failed: {}", resp.status());
}
let mut buffer = Vec::new();
resp.read_to_end(&mut buffer)?;
Ok(buffer)
}
pub fn resolve_artifacts(
&self,
dependencies: &serde_json::Value,
) -> Result<HashMap<String, String>> {
let auth = self
.auth_header()
.context("Not logged in. Run `xsil login` first to resolve dependency artifacts.")?;
let body = serde_json::json!({ "dependencies": dependencies });
let resp = self
.client
.post(format!("{}/api/artifacts/resolve", self.base_url))
.header("Authorization", auth)
.json(&body)
.send()
.context("Failed to reach artifact resolver endpoint")?;
let status = resp.status();
let json: serde_json::Value = resp
.json()
.context("Invalid response from artifact resolver")?;
if !status.is_success() {
bail!(
"Artifact resolve failed ({}): {}",
status,
json.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error")
);
}
let parsed: ResolveArtifactsResponse = serde_json::from_value(json)
.context("Failed to parse resolved artifacts response")?;
if !parsed.missing.is_empty() {
let sample = parsed
.missing
.iter()
.take(3)
.map(|m| format!("{}@{} [{}]", m.name, m.version, m.platform))
.collect::<Vec<_>>()
.join(", ");
bail!(
"Missing dependency artifacts in ExtenSilica registry: {}{}",
sample,
if parsed.missing.len() > 3 { "..." } else { "" }
);
}
let mut out = HashMap::new();
for r in parsed.resolved {
let key = Self::dependency_key(&r.name, &r.version, &r.platform, &r.sha256);
out.insert(key, r.url);
}
Ok(out)
}
}