Files
OpenResearch/src/config.rs
T
Daniel Kim e888995821 Add orx version / orx update and a passive update nudge
- `orx --version` (clap) and `orx version [--check] [--json]`: prints the
  CLI version and compares it to the latest GitHub release. `--json` is
  the agent-facing form (stable stdout object, exit 0 either way).
- `orx update [--dry-run] [--force]`: updates in place by re-running the
  cargo-dist release installer pinned to the existing install prefix
  (CARGO_DIST_FORCE_INSTALL_DIR), the same mechanism as axoupdater/uv but
  without the native-tls dependency that conflicts with our musl builds.
  Guarded by the cargo-dist install receipt (refuses cargo-install copies,
  path/version mismatches, Nix/Homebrew paths, unwritable bin dirs), an
  exclusive flock against concurrent updates, and run via `sh script` so
  noexec /tmp mounts don't break it.
- Passive nudge: at most one background latest-version check per 24h
  (cached in the config dir), printed to stderr only on interactive
  terminals, never in CI/pipes, never altering exit codes. Opt out with
  ORX_NO_UPDATE_CHECK, NO_UPDATE_NOTIFIER, or OPENRESEARCH_CLI_DISABLE_UPDATE=1.

Latest-version discovery uses the releases/latest/download/dist-manifest.json
permalink (CDN redirect, no GitHub API rate limit) instead of the REST API,
whose 60 req/h/IP unauthenticated cap is routinely exhausted on the
datacenter IPs agents run from.
2026-06-11 14:17:15 -07:00

102 lines
3.6 KiB
Rust

//! Credential storage, XDG paths, and the default API URL.
//!
//! Credentials live at
//! `$XDG_CONFIG_HOME/openresearch/credentials.json` (falling back to
//! `~/.config/openresearch/credentials.json`), written owner-only (mode 0600).
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::error::Result;
/// Base URL of the API. Defaults to prod (`https://api.openresearch.sh`), so a
/// plain `orx login` just works. Override per-invocation with `--api-url` (handled
/// in the `login` command) or set the `OPENRESEARCH_API_URL` env var to point at
/// local dev, e.g. `OPENRESEARCH_API_URL=http://localhost:4000 orx login`.
pub fn default_api_url() -> String {
std::env::var("OPENRESEARCH_API_URL")
.unwrap_or_else(|_| "https://api.openresearch.sh".to_string())
}
/// Base URL for the alphaXiv JSON API (full-text literature search). Unlike the
/// OpenResearch API these endpoints are public — no token — and live on a
/// different host. Override with `ALPHAXIV_API_URL`.
pub fn alphaxiv_api_url() -> String {
std::env::var("ALPHAXIV_API_URL").unwrap_or_else(|_| "https://api.alphaxiv.org".to_string())
}
/// Base URL for the alphaXiv web app, which serves the per-paper `.md` routes
/// (`/overview/<id>.md` report, `/abs/<id>.md` full text). Default is the `www.`
/// host so we skip the apex→www 301. Override with `ALPHAXIV_WEB_URL`.
pub fn alphaxiv_web_url() -> String {
std::env::var("ALPHAXIV_WEB_URL").unwrap_or_else(|_| "https://www.alphaxiv.org".to_string())
}
/// Stored credentials: the API base URL and the bearer token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
#[serde(rename = "apiUrl")]
pub api_url: String,
pub token: String,
}
pub(crate) fn config_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
});
base.join("openresearch")
}
fn credentials_path() -> PathBuf {
config_dir().join("credentials.json")
}
/// Reads stored credentials. Returns `Ok(None)` when the file is missing,
/// unreadable, malformed, or missing required fields — matching the TS
/// `loadCredentials`, which swallows all errors and returns `null`.
pub async fn load_credentials() -> Result<Option<Credentials>> {
let path = credentials_path();
let raw = match fs::read_to_string(&path).await {
Ok(raw) => raw,
Err(_) => return Ok(None),
};
match serde_json::from_str::<Credentials>(&raw) {
Ok(creds) if !creds.api_url.is_empty() && !creds.token.is_empty() => Ok(Some(creds)),
_ => Ok(None),
}
}
/// Persists credentials as pretty JSON with a trailing newline, mode 0600.
pub async fn save_credentials(creds: &Credentials) -> Result<()> {
let path = credentials_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let body = format!("{}\n", serde_json::to_string_pretty(creds)?);
fs::write(&path, body).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
fs::set_permissions(&path, perms).await?;
}
Ok(())
}
/// Removes the credentials file. Succeeds even if it does not exist (`force`).
pub async fn clear_credentials() -> Result<()> {
match fs::remove_file(credentials_path()).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}