diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9097da0..7018f32 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -231,6 +231,86 @@ async fn ensure_core_user_and_token() -> Result { Ok(raw.trim().to_owned()) } +/// POST `/api/v1/user/repos` to create a repo in the authenticated +/// user's own namespace. `token` belongs to the user we want the +/// repo owned by (we use `core`'s token for `core/meta`). Idempotent: +/// HTTP 409 ("repository already exists") is treated as success. +pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { + let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#); + let url = format!("{FORGE_HTTP}/api/v1/user/repos"); + let out = Command::new("curl") + .args([ + "-sS", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-H", + &format!("Authorization: token {token}"), + "-d", + &body, + &url, + ]) + .output() + .await + .context("invoke curl POST /api/v1/user/repos")?; + let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + match code.as_str() { + "201" => { + tracing::info!(%name, "forge: created repo"); + Ok(()) + } + "409" | "422" => { + tracing::debug!(%name, "forge: repo already exists"); + Ok(()) + } + other => anyhow::bail!( + "POST /api/v1/user/repos name={name} returned HTTP {other}" + ), + } +} + +/// Read the persisted core token, or None when the forge isn't +/// seeded yet. Cheap — just a file read. +pub fn core_token() -> Option { + std::fs::read_to_string(CORE_TOKEN_PATH) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) +} + +/// Push `dir` (the meta repo) to `core/meta` on the local forge. +/// Best-effort: returns Err which callers log + ignore. No-op when +/// the core token isn't present (forge not enabled). +pub async fn push_meta(dir: &Path) -> Result<()> { + let Some(token) = core_token() else { + return Ok(()); + }; + // Token-in-URL push. Forgejo accepts `oauth2:` or just + // any-username:; using `core` matches the owner so the + // remote name is self-describing. + let url = format!("http://core:{token}@localhost:3000/core/meta.git"); + let out = Command::new("git") + .current_dir(dir) + .args(["push", "--force", &url, "HEAD:main"]) + .output() + .await + .context("invoke git push core/meta")?; + if !out.status.success() { + anyhow::bail!( + "git push core/meta failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + tracing::info!("forge: pushed meta to core/meta"); + Ok(()) +} + /// POST `/api/v1/orgs` to create an org named `name`. Idempotent: /// HTTP 422 ("user already exists") is treated as success. async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { @@ -297,6 +377,12 @@ pub async fn ensure_all() { tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); } } + // Meta repo lives at core/meta — pushed from git_commit in + // meta.rs on every deploy/lock-update. Make sure it exists + // before the first push hits a 404. + if let Err(e) = ensure_repo("meta", token).await { + tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); + } } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8f5bb88..6774e66 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -342,7 +342,15 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> { message, ], ) - .await + .await?; + // Best-effort mirror to the bundled forge. No-op when the forge + // isn't seeded (no core token on disk); push failures log a warn + // but don't bubble up — a missing mirror shouldn't fail an + // otherwise successful deploy. + if let Err(e) = crate::forge::push_meta(dir).await { + tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)"); + } + Ok(()) } async fn nix(dir: &Path, args: &[&str]) -> Result<()> {