diff --git a/Cargo.lock b/Cargo.lock index 1da33aa..3083bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,6 +585,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64", "clap", "hive-fr0nt", "hive-sh4re", diff --git a/Cargo.toml b/Cargo.toml index f41eac0..a53b5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" axum = { version = "0.8", features = ["ws"] } +base64 = "0.22" clap = { version = "4", features = ["derive"] } hive-fr0nt = { path = "hive-fr0nt" } hive-sh4re = { path = "hive-sh4re" } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 50afeb2..169a50e 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] anyhow.workspace = true axum.workspace = true +base64.workspace = true reqwest.workspace = true clap.workspace = true hive-fr0nt.workspace = true diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 974263b..392f5d1 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -20,6 +20,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use base64::Engine; use reqwest::StatusCode; use tokio::process::Command; @@ -32,6 +33,16 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// itself to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; +/// Marker that records whether `ensure_core_avatar` has successfully +/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot: +/// the upload runs once, the marker is written, subsequent startups skip +/// the call. Delete to force re-upload. +const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; +/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the +/// admin avatar API so the `core` Forgejo user shows the project mark +/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of +/// the default hash identicon. +const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png"); /// Forgejo org grouping every agent's applied config repo. Core is a /// site admin and reads + writes every repo here; agents are NOT /// members and the repos are private, so no agent — not even the one @@ -266,6 +277,32 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { mint_and_persist_token(name, &token_path(name)).await } +/// Set `core`'s Forgejo avatar to the hyperhive logo once, then +/// remember it so subsequent startups don't re-upload (issue #320). +/// Best-effort — any non-2xx is logged at the caller; the project +/// runs fine with the default hash identicon. +async fn ensure_core_avatar(token: &str) -> Result<()> { + let marker = std::path::Path::new(CORE_AVATAR_MARKER); + if marker.exists() { + return Ok(()); + } + let body = format!( + r#"{{"image":"{}"}}"#, + base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG), + ); + let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); + let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; + if !status.is_success() { + anyhow::bail!("set core avatar: HTTP {status}"); + } + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(marker, "").ok(); + tracing::info!("forge: set core user avatar to hyperhive logo"); + Ok(()) +} + /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo /// API calls (org creation now, meta-repo push later). Returns the @@ -591,6 +628,9 @@ pub async fn ensure_all() { if let Err(e) = ensure_repo("meta", token).await { tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); } + if let Err(e) = ensure_core_avatar(token).await { + tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); + } } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep");