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())

View file

@ -5,6 +5,11 @@
//! `HIVE_FORGE_REPO` — default repo, e.g. `hyperhive/hyperhive`
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
//!
//! The global `-f/--forge <label>` flag targets a dashboard-provisioned
//! external forge account instead: it reads `forge-<label>-token` +
//! `forge-<label>.json` (base URL) from the state dir rather than
//! `HIVE_FORGE_URL`/`forge-token`. See `client::Client::from_env`.
//!
//! Single binary with verb subcommands. Replaces the prior bash
//! script (`hive-forge-tools.nix`) so that agents and operators get
//! the same error handling, exit codes, and JSON shapes regardless
@ -36,6 +41,15 @@ struct Cli {
/// positional the bash helper used.
#[arg(short = 'r', long, global = true)]
repo: Option<String>,
/// Target an external forge account provisioned via the dashboard's
/// FORGES tab, by label, instead of the internal forge. Reads
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` for the token and
/// `forge-<label>.json` for the base URL (the same two files the
/// dashboard writes) instead of `HIVE_FORGE_URL`/`forge-token`.
/// Orthogonal to `-r/--repo`, which still just picks which repo on
/// whichever forge is selected.
#[arg(short = 'f', long, global = true)]
forge: Option<String>,
/// Emit JSON output instead of the verb's default human-readable
/// shape, for verbs that support both. Verbs whose
/// only output is already JSON (`issue`, `pr`, etc.) ignore this
@ -181,7 +195,8 @@ enum Verb {
fn main() -> Result<()> {
let cli = Cli::parse();
let client = client::Client::from_env(cli.repo, cli.json).context("initialize forge client")?;
let client = client::Client::from_env(cli.repo, cli.json, cli.forge)
.context("initialize forge client")?;
match cli.verb {
Verb::View(a) => verbs::view::run(&client, a),
Verb::Issue(a) => verbs::issue_cmd::run(&client, a),