feat(#2014): hivectl open verb + Urls host request for web surfaces

This commit is contained in:
damocles 2026-06-26 23:13:06 +02:00 committed by mara
commit cae1dd8147
6 changed files with 200 additions and 32 deletions

View file

@ -204,6 +204,19 @@ enum Cmd {
#[command(subcommand)]
cmd: SubvolCmd,
},
/// Print (and best-effort open in a browser) a hive web surface URL.
///
/// Resolves the URL from the running daemon (`HostRequest::Urls`), so
/// custom forge / matrix domains work without guessing `forge.<domain>`.
/// Prints the URL unconditionally — the reliable core, since the host
/// is usually headless / driven over SSH where `xdg-open` is a no-op —
/// then tries `xdg-open` as a convenience. Bare `hivectl open` opens the
/// operator dashboard.
Open {
/// Which surface to open. Defaults to the operator dashboard.
#[arg(value_enum, default_value_t = OpenTarget::Home)]
target: OpenTarget,
},
/// Emit the full CLI reference as `CommonMark` to stdout.
///
/// Hidden tooling command (not part of day-to-day operator admin):
@ -227,6 +240,17 @@ enum Cmd {
},
}
/// Which hive web surface `hivectl open` targets.
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
enum OpenTarget {
/// The operator dashboard (`https://<domain>/`).
Home,
/// The forge (Forgejo) web UI.
Forge,
/// The matrix GUI (fluffychat).
Matrix,
}
/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag
/// set the verb targets **everything** (all sub-agents + every controllable
/// infra container). Setting any flag restricts to the selected classes,
@ -628,6 +652,7 @@ async fn main() -> Result<()> {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())
}
Cmd::Open { target } => open_url(&socket, target).await,
Cmd::Completions { shell } => {
generate_completions(shell);
Ok(())
@ -635,6 +660,41 @@ async fn main() -> Result<()> {
}
}
/// `open <home|forge|matrix>` — resolve the surface URL from the daemon,
/// print it, then best-effort `xdg-open` it. Printing is the reliable
/// core (headless / SSH hosts where no browser opener exists); the open
/// is convenience on top, so a missing/failed `xdg-open` is not an error.
async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> {
let urls = query_hive_urls(socket).await.context(
"could not reach the hive-c0re daemon for URLs — is hive-c0re running? \
(the socket is at /run/hyperhive/host.sock)",
)?;
let (url, hint) = match target {
OpenTarget::Home => (
urls.home,
"the dashboard URL needs `services.hyperhive.domain` to be set",
),
OpenTarget::Forge => (
urls.forge,
"the public forge URL needs `services.hyperhive.forge.behindGateway = true`",
),
OpenTarget::Matrix => (
urls.matrix,
"the matrix GUI URL needs `services.hyperhive.matrix.gui.enable = true`",
),
};
let url = url.with_context(|| format!("no URL available for this surface — {hint}"))?;
println!("{url}");
// Best-effort: many hosts are headless, so a missing opener or a
// non-zero exit is fine — the URL is already printed.
match std::process::Command::new("xdg-open").arg(&url).status() {
Ok(status) if status.success() => {}
Ok(status) => eprintln!("note: xdg-open exited with {status} (URL printed above)"),
Err(e) => eprintln!("note: could not run xdg-open ({e}) (URL printed above)"),
}
Ok(())
}
/// Emit a shell completion script for `hivectl` to stdout. Walks the clap
/// command tree (the single source of truth — same tree `markdown-docs`
/// renders) so completions never drift from the actual verbs/flags.
@ -658,14 +718,20 @@ const WG_INTERFACE: &str = "wg-hive";
const HIVE_TLS_CA_PATH: &str = "/var/lib/hive-tls/ca.pem";
/// Best-effort query for this hive's domain from the running daemon
/// (`HostRequest::HiveDomain`, which reads `HYPERHIVE_HIVE_DOMAIN` from
/// c0re's service env). `None` when the daemon is unreachable or the
/// domain is unset — callers decide whether that's fatal.
/// (`HostRequest::Urls`, which reads `HYPERHIVE_HIVE_DOMAIN` from c0re's
/// service env). `None` when the daemon is unreachable or the domain is
/// unset — callers decide whether that's fatal.
async fn query_hive_domain(socket: &Path) -> Option<String> {
hive_c0re::client::request(socket, hive_sh4re::HostRequest::HiveDomain)
query_hive_urls(socket).await.and_then(|u| u.domain)
}
/// Best-effort query for this hive's domain + browser-facing web URLs
/// (`HostRequest::Urls`). `None` when the daemon is unreachable.
async fn query_hive_urls(socket: &Path) -> Option<hive_sh4re::HiveUrls> {
hive_c0re::client::request(socket, hive_sh4re::HostRequest::Urls)
.await
.ok()
.and_then(|r| r.domain)
.and_then(|r| r.urls)
}
/// Require this hive's domain from the daemon for snippet generation.

View file

@ -155,16 +155,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
.collect();
HostResponse::agent_statuses(rows)
}
// The hive domain is injected into c0re's service env by
// hive-c0re.nix (`HYPERHIVE_HIVE_DOMAIN`); surface it so the
// operator CLI can fill in this hive's own identity.
HostRequest::HiveDomain => HostResponse::hive_domain(
// Treat an empty env value as unset — otherwise the CLI
// would emit `swarm.peers."" = …`, invalid nix.
std::env::var("HYPERHIVE_HIVE_DOMAIN")
.ok()
.filter(|d| !d.is_empty()),
),
// The hive domain + per-surface public URLs are injected into
// c0re's service env by hive-c0re.nix; surface them so the
// operator CLI can fill in this hive's own identity (the
// federation peer-config block) and open the web surfaces
// (`hivectl open`).
HostRequest::Urls => HostResponse::urls(hive_urls()),
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
HostRequest::Approve { id } => {
actions::approve(coord.clone(), *id).await?;
@ -261,7 +257,7 @@ async fn handle_restart_all() -> Result<HostResponse> {
error: Some(errors.join("; ")),
agents: Some(ok_agents),
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
})
}
@ -379,6 +375,25 @@ fn is_broad_scope(scope: &LifecycleScope) -> bool {
scope.agents || scope.is_everything()
}
/// Assemble this hive's domain + browser-facing web URLs from c0re's
/// service env (injected by hive-c0re.nix). Each field is `None` when its
/// surface isn't browser-reachable (domain unset, forge not behind the
/// gateway, matrix GUI off), so the CLI can hint precisely instead of
/// opening a dead link. Scheme matches the existing `HIVE_FORGE_PUBLIC_URL`
/// convention (gateway terminates TLS, so https).
fn hive_urls() -> hive_sh4re::HiveUrls {
// Treat an empty env value as unset everywhere — an empty domain would
// otherwise render `swarm.peers."" = …` (invalid nix) and `https:///`.
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let domain = env("HYPERHIVE_HIVE_DOMAIN");
hive_sh4re::HiveUrls {
home: domain.as_ref().map(|d| format!("https://{d}/")),
forge: env("HIVE_FORGE_PUBLIC_URL"),
matrix: env("HIVE_MATRIX_PUBLIC_URL"),
domain,
}
}
async fn scoped_agents(scope: &LifecycleScope) -> Result<Vec<String>> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
@ -430,7 +445,7 @@ fn finish_lifecycle(ok_items: Vec<String>, errors: &[String]) -> HostResponse {
error: Some(errors.join("; ")),
agents: Some(ok_items),
approvals: None,
domain: None,
urls: None,
agent_statuses: None,
}
}