CLI now reads `[shared].registry_url` and `[cli].cookie` from the unified file via maestro-config instead of `~/.config/maestro/cli.toml`. The two persistence paths are now distinct: - `maestro set-registry <url>` → writes `[shared].registry_url` - captured Set-Cookie on first contact → writes `[cli].cookie` Both go through read-modify-write so they preserve any `[daemon]` / `[registry]` content already in the file. Env-var renames on the CLI flag layer: - `--registry-url` reads `MAESTRO_SHARED_REGISTRY_URL` (was `MAESTRO_REGISTRY_URL`) - `--config` reads `MAESTRO_CONFIG` (was `MAESTRO_CLI_CONFIG`) Default config path is now `~/.config/maestro/config.toml`.
175 lines
5.5 KiB
Rust
175 lines
5.5 KiB
Rust
//! Thin HTTP client wrapper around `reqwest` that persists the
|
|
//! `maestro_session` cookie issued by the registry's cookie middleware.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE};
|
|
use reqwest::{Method, Response, StatusCode};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::Serialize;
|
|
|
|
use crate::config::{CliConfig, ConfigError};
|
|
|
|
const COOKIE_NAME: &str = "maestro_session";
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum HttpError {
|
|
#[error("http: {0}")]
|
|
Reqwest(#[from] reqwest::Error),
|
|
#[error("server returned {status}: {body}")]
|
|
ServerError { status: StatusCode, body: String },
|
|
#[error("decode: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
#[error("config: {0}")]
|
|
Config(#[from] ConfigError),
|
|
#[error("invalid header: {0}")]
|
|
InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
|
|
}
|
|
|
|
/// HTTP client bound to a single registry. Persists the auth cookie back to
|
|
/// disk when the registry sets one (first-contact bootstrap from
|
|
/// `crates/maestro-registry/src/auth.rs`).
|
|
#[derive(Debug)]
|
|
pub struct RegistryClient {
|
|
base: String,
|
|
inner: reqwest::Client,
|
|
/// Cookie value (the JWT body), shared with the config-persister.
|
|
cookie: Arc<Mutex<Option<String>>>,
|
|
/// Where to write the config back when the cookie changes.
|
|
config_path: PathBuf,
|
|
}
|
|
|
|
impl RegistryClient {
|
|
pub fn new(base: String, cookie: Option<String>, config_path: PathBuf) -> Self {
|
|
let inner = reqwest::Client::builder()
|
|
.user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION")))
|
|
.build()
|
|
.expect("build reqwest client");
|
|
Self {
|
|
base: base.trim_end_matches('/').to_string(),
|
|
inner,
|
|
cookie: Arc::new(Mutex::new(cookie)),
|
|
config_path,
|
|
}
|
|
}
|
|
|
|
pub fn base(&self) -> &str {
|
|
&self.base
|
|
}
|
|
|
|
/// Snapshot of the current cookie (mostly for tests).
|
|
pub fn current_cookie(&self) -> Option<String> {
|
|
self.cookie.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Send a request, attaching the persisted cookie. If the response sets a
|
|
/// new cookie (first contact, or a rotation), persist it back to disk.
|
|
async fn send(
|
|
&self,
|
|
method: Method,
|
|
path: &str,
|
|
body: Option<serde_json::Value>,
|
|
) -> Result<Response, HttpError> {
|
|
let url = format!("{}{}", self.base, path);
|
|
let mut headers = HeaderMap::new();
|
|
if let Some(c) = self.cookie.lock().unwrap().as_ref() {
|
|
headers.insert(
|
|
COOKIE,
|
|
HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?,
|
|
);
|
|
}
|
|
let mut req = self.inner.request(method, &url).headers(headers);
|
|
if let Some(b) = body {
|
|
req = req.json(&b);
|
|
}
|
|
let resp = req.send().await?;
|
|
self.capture_cookie(resp.headers())?;
|
|
Ok(resp)
|
|
}
|
|
|
|
fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> {
|
|
let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else {
|
|
return Ok(());
|
|
};
|
|
let Some(val) = extract_cookie_value(raw, COOKIE_NAME) else {
|
|
return Ok(());
|
|
};
|
|
let mut guard = self.cookie.lock().unwrap();
|
|
if guard.as_deref() == Some(val) {
|
|
return Ok(());
|
|
}
|
|
*guard = Some(val.to_string());
|
|
// Persist; on failure, log but don't abort the request.
|
|
if let Err(e) = CliConfig::save_cookie(&self.config_path, val) {
|
|
tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
|
|
let resp = self.send(Method::GET, path, None).await?;
|
|
ok_or_body(resp)
|
|
.await?
|
|
.json::<T>()
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
|
|
&self,
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, HttpError> {
|
|
let v = serde_json::to_value(body)?;
|
|
let resp = self.send(Method::POST, path, Some(v)).await?;
|
|
ok_or_body(resp)
|
|
.await?
|
|
.json::<T>()
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
pub async fn delete(&self, path: &str) -> Result<(), HttpError> {
|
|
let resp = self.send(Method::DELETE, path, None).await?;
|
|
ok_or_body(resp).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn ok_or_body(resp: Response) -> Result<Response, HttpError> {
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
return Ok(resp);
|
|
}
|
|
let body = resp.text().await.unwrap_or_default();
|
|
Err(HttpError::ServerError { status, body })
|
|
}
|
|
|
|
/// Extract `name=value` from a `Set-Cookie:` value. Ignores attributes.
|
|
pub fn extract_cookie_value<'a>(raw: &'a str, name: &str) -> Option<&'a str> {
|
|
let first = raw.split(';').next()?.trim();
|
|
let (k, v) = first.split_once('=')?;
|
|
(k.trim() == name).then(|| v.trim())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn extracts_named_cookie_value() {
|
|
let raw = "maestro_session=abc.def.ghi; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000";
|
|
assert_eq!(
|
|
extract_cookie_value(raw, "maestro_session"),
|
|
Some("abc.def.ghi")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn returns_none_for_other_cookie_name() {
|
|
let raw = "other=1; HttpOnly";
|
|
assert_eq!(extract_cookie_value(raw, "maestro_session"), None);
|
|
}
|
|
}
|