diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index fb5af985..5100315c 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -36,6 +36,7 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl quota limit`↴](#hivectl-quota-limit) * [`hivectl subvol`↴](#hivectl-subvol) * [`hivectl subvol upgrade`↴](#hivectl-subvol-upgrade) +* [`hivectl open`↴](#hivectl-open) * [`hivectl completions`↴](#hivectl-completions) ## `hivectl` @@ -58,6 +59,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `restart` — Restart containers hive-wide — `stop` then `start` over the same scope. Bare `hivectl restart` restarts **everything** (all sub-agents plus the ci/forge/gateway/matrix infra containers); the same scope flags as `stop`/`start` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). If the stop phase reports a failure the start phase is skipped so the operator can investigate. Requires the hive-c0re daemon * `quota` — Per-agent disk accounting + optional quotas via btrfs qgroups * `subvol` — btrfs subvolume management for agent state dirs +* `open` — Print (and best-effort open in a browser) a hive web surface URL * `completions` — Generate a shell completion script for `hivectl` and print it to stdout ###### **Options:** @@ -527,6 +529,31 @@ Convert an existing plain-dir agent state root into a btrfs subvolume in place. +## `hivectl open` + +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.`. 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. + +**Usage:** `hivectl open [TARGET]` + +###### **Arguments:** + +* `` — Which surface to open. Defaults to the operator dashboard + + Default value: `home` + + Possible values: + - `home`: + The operator dashboard (`https:///`) + - `forge`: + The forge (Forgejo) web UI + - `matrix`: + The matrix GUI (fluffychat) + + + + ## `hivectl completions` Generate a shell completion script for `hivectl` and print it to stdout. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 1ff0ac41..519e8953 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -151,3 +151,29 @@ lands in a faithful copy of the agent's environment: the hyperhive/matrix MCP tools (which `--continue` needs to replay a tool-using history), and the role prompt. Each flag is included only when its file exists. + +## Open + +Print (and best-effort open in a browser) one of the hive's web surfaces. +Requires the `hive-c0re` daemon to be running. + +```bash +hivectl open # operator dashboard (same as `open home`) +hivectl open home # operator dashboard (https:///) +hivectl open forge # the forge (Forgejo) web UI +hivectl open matrix # the matrix GUI (fluffychat) +``` + +The URL is resolved from the running daemon (`HostRequest::Urls`), which +reads the per-surface public URLs from c0re's service env — so custom +forge / matrix domains resolve correctly instead of assuming +`forge.`. The URL is **always printed** (the reliable core, since +the host is usually headless / driven over SSH), then `xdg-open` is tried +as a convenience — a missing or failing opener is reported as a note, not +an error. + +A surface has no URL when it isn't browser-reachable: `home` needs +`services.hyperhive.domain`; `forge` needs +`services.hyperhive.forge.behindGateway = true`; `matrix` needs +`services.hyperhive.matrix.gui.enable = true`. In those cases the command +exits with a hint naming the option to set. diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index d65df71c..6e050532 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -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.`. + /// 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:///`). + 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::()); 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 ` — 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 { - 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_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. diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 21402aec..88817f6b 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -155,16 +155,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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 { 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> { use std::collections::BTreeSet; let mut set: BTreeSet = BTreeSet::new(); @@ -430,7 +445,7 @@ fn finish_lifecycle(ok_items: Vec, errors: &[String]) -> HostResponse { error: Some(errors.join("; ")), agents: Some(ok_items), approvals: None, - domain: None, + urls: None, agent_statuses: None, } } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index be7a8e7a..e896a063 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -50,10 +50,13 @@ pub enum HostRequest { /// Reuses the dashboard's per-agent `ContainerView` aggregation. AgentStatus, /// Report this hive's canonical DNS domain - /// (`services.hyperhive.domain`), or `None` when unset. Lets the - /// operator CLI fill in the hive's own identity (e.g. the federation - /// peer-config block) without the operator retyping it. - HiveDomain, + /// (`services.hyperhive.domain`) plus the browser-facing home / + /// forge / matrix URLs, daemon-sourced so custom forge/matrix + /// domains resolve correctly. Each URL is `None` when its subsystem + /// is unreachable from a browser (e.g. forge not behind the gateway, + /// matrix GUI disabled). Backs `hivectl open` + the federation + /// peer-config block (which reads the bare `domain`). + Urls, /// List pending approval requests. Pending, /// Approve a pending request by id; the action runs immediately. @@ -132,6 +135,25 @@ impl LifecycleScope { } } +/// This hive's canonical domain plus the browser-facing URLs for its +/// web surfaces — the `Urls` request result. Every field is `None` when +/// the corresponding surface can't be reached from a browser (domain +/// unset, forge not behind the gateway, matrix GUI disabled), so the CLI +/// can give a precise hint instead of opening a dead link. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HiveUrls { + /// Canonical hive domain (`services.hyperhive.domain`). + pub domain: Option, + /// Operator dashboard root (`https:///`). + pub home: Option, + /// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — only the + /// behind-gateway public URL; `None` on direct-port forge deploys. + pub forge: Option, + /// Matrix GUI (fluffychat) browser URL — `None` when the matrix GUI + /// is disabled. + pub matrix: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostResponse { pub ok: bool, @@ -141,10 +163,10 @@ pub struct HostResponse { pub agents: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub approvals: Option>, - /// This hive's canonical DNS domain — `HiveDomain` result. `None` - /// when the domain is unset (no `services.hyperhive.domain`). + /// `Urls` result — this hive's domain plus the browser-facing + /// home / forge / matrix URLs. `None` for every other request kind. #[serde(default, skip_serializing_if = "Option::is_none")] - pub domain: Option, + pub urls: Option, /// `AgentStatus` result — one row per managed agent with its /// running/health flags + technical state. `None` for every other /// request kind. @@ -245,7 +267,7 @@ impl HostResponse { error: None, agents: None, approvals: None, - domain: None, + urls: None, agent_statuses: None, } } @@ -257,7 +279,7 @@ impl HostResponse { error: Some(message.into()), agents: None, approvals: None, - domain: None, + urls: None, agent_statuses: None, } } @@ -269,7 +291,7 @@ impl HostResponse { error: None, agents: Some(agents), approvals: None, - domain: None, + urls: None, agent_statuses: None, } } @@ -281,20 +303,20 @@ impl HostResponse { error: None, agents: None, approvals: Some(approvals), - domain: None, + urls: None, agent_statuses: None, } } - /// `HiveDomain` result — this hive's canonical domain (or `None`). + /// `Urls` result — this hive's domain + browser-facing web URLs. #[must_use] - pub fn hive_domain(domain: Option) -> Self { + pub fn urls(urls: HiveUrls) -> Self { Self { ok: true, error: None, agents: None, approvals: None, - domain, + urls: Some(urls), agent_statuses: None, } } @@ -307,7 +329,7 @@ impl HostResponse { error: None, agents: None, approvals: None, - domain: None, + urls: None, agent_statuses: Some(rows), } } diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index fd35d38a..52e8e07d 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -953,6 +953,18 @@ in # falls back to `:3000`. HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}"; } + // + lib.optionalAttrs + ( + config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null + ) + { + # Browser-facing matrix GUI (fluffychat) URL — the gateway + # vhost (`matrix.`). Surfaced via the daemon's `Urls` + # request for `hivectl open matrix`. Absent when the GUI is off + # or no gatewayHost is set (no browser-reachable matrix vhost). + HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/"; + } // lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) { # Peer hives serialised as a JSON array of {domain, cert_fingerprint, # wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()