hive-forge: add -f/--forge <label> selector for external forge accounts

Targets a dashboard-provisioned external forge account (FORGES tab)
instead of the internal forge: resolves `forge-<label>-token` for the
token and `forge-<label>.json`'s base_url for the URL, the same two
files dashboard/extra_forges.rs writes, instead of
HIVE_FORGE_URL/forge-token. Falls back to today's behavior when unset.
Orthogonal to -r/--repo.

An unknown label gives a clear error listing the labels actually found
in the state dir instead of a raw file-not-found. The base_url JSON key
is read via a typed sidecar struct pinned to what extra_forges.rs
writes, so the read side can't silently drift from the write side.
This commit is contained in:
iris 2026-07-14 19:31:08 +02:00
commit 8e58793f79
2 changed files with 110 additions and 11 deletions

View file

@ -11,7 +11,7 @@ use anyhow::{Context, Result, bail};
use forgejo_api::{Auth, ForgejoError};
use reqwest::blocking::{Client as HttpClient, Response};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Default Forgejo URL when `HIVE_FORGE_URL` is unset.
@ -54,12 +54,18 @@ impl Client {
/// priority over `HIVE_FORGE_REPO`; the env var is the fallback
/// default. `json_mode` comes from the global `--json` flag —
/// per-verb output formatters key off it via `Client::json_mode`.
pub fn from_env(repo_override: Option<String>, json_mode: bool) -> Result<Self> {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
/// `forge_label` (from the global `-f/--forge` flag) targets a
/// dashboard-provisioned external forge account instead of the
/// internal forge — see [`resolve_credentials`].
pub fn from_env(
repo_override: Option<String>,
json_mode: bool,
forge_label: Option<String>,
) -> Result<Self> {
let (base, token) = resolve_credentials(forge_label.as_deref())?;
let default_repo = repo_override
.or_else(|| std::env::var("HIVE_FORGE_REPO").ok())
.unwrap_or_else(|| DEFAULT_REPO.to_owned());
let token = read_token().context("read forge-token")?;
let url = url::Url::parse(&base).with_context(|| format!("parse HIVE_FORGE_URL {base}"))?;
let api = forgejo_api::sync::Forgejo::new(Auth::Token(&token), url)
@ -225,15 +231,93 @@ pub fn index(n: u64) -> Result<i64> {
i64::try_from(n).with_context(|| format!("number {n} out of range"))
}
/// Resolve the `(base_url, token)` pair the client authenticates with.
/// `None` (the default) resolves the internal forge exactly as before:
/// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`.
/// `Some(label)` (from `-f/--forge <label>`) instead resolves a
/// dashboard-provisioned external forge account: the token comes from
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from
/// the `base_url` key of the sibling `forge-<label>.json` sidecar —
/// the exact same two files `dashboard/extra_forges.rs` writes, so the
/// read side can't drift from the write side. An unknown label (either
/// file missing) is a clear error listing the labels actually found in
/// the state dir, not a raw file-not-found.
fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
let Some(label) = forge_label else {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
let token = read_token().context("read forge-token")?;
return Ok((base, token));
};
let dir = state_dir();
let token_path = dir.join(format!("forge-{label}-token"));
let sidecar_path = dir.join(format!("forge-{label}.json"));
let token = std::fs::read_to_string(&token_path).map(|s| s.trim().to_owned());
let sidecar = std::fs::read_to_string(&sidecar_path)
.ok()
.and_then(|s| serde_json::from_str::<ForgeSidecar>(&s).ok());
if let (Ok(token), Some(sidecar)) = (token, sidecar) {
return Ok((sidecar.base_url, token));
}
let known = provisioned_labels(&dir);
let known = if known.is_empty() {
"(none provisioned)".to_owned()
} else {
known.join(", ")
};
bail!("hive-forge: no such forge {label:?} — provisioned forges: {known}");
}
/// Sidecar shape `dashboard/extra_forges.rs` writes alongside each
/// `forge-<label>-token` file: just the base URL, pinned to the same
/// `base_url` JSON key the write side uses.
#[derive(Deserialize)]
struct ForgeSidecar {
base_url: String,
}
/// Scan the state dir for every `forge-<label>-token` file (mirroring
/// `dashboard/extra_forges.rs`'s own listing logic) and return the
/// labels found, sorted. Used to build a helpful "did you mean one of
/// these" error when `--forge <label>` doesn't resolve. Best-effort:
/// an unreadable state dir yields an empty list rather than erroring
/// (the caller already has its own error to report).
fn provisioned_labels(dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut labels: Vec<String> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|ft| ft.is_file()))
.filter_map(|e| {
e.file_name()
.to_str()?
.strip_prefix("forge-")?
.strip_suffix("-token")
.filter(|label| !label.is_empty())
.map(str::to_owned)
})
.collect();
labels.sort();
labels
}
/// The configured `HYPERHIVE_STATE_DIR`, falling back to `$PWD` when
/// unset — matches `read_token`'s fallback for the plain internal-forge
/// path.
fn state_dir() -> PathBuf {
match std::env::var("HYPERHIVE_STATE_DIR") {
Ok(s) if !s.is_empty() => PathBuf::from(s),
_ => PathBuf::from("."),
}
}
/// Locate and read the forge token. Falls back to `$PWD/forge-token`
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
fn read_token() -> Result<String> {
let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default();
let path = if state_dir.is_empty() {
PathBuf::from("forge-token")
} else {
PathBuf::from(state_dir).join("forge-token")
};
let path = state_dir().join("forge-token");
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?;
Ok(raw.trim().to_owned())