From ec293c0a0a94c269a04e127a47cf6bb71471107a Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Wed, 28 Oct 2026 10:13:51 +0100 Subject: [PATCH] Add si site to publish a repository as a static site si site view shows the address and state. si site publish starts a publish, and with --wait it polls every two seconds for up to about 90 seconds until the site is live or has failed. A rate-limited publish shows the server's message. si site unpublish takes it offline after a confirmation. The client crate gets site, publish_site and unpublish_site, with the Site type. Co-Authored-By: Claude Opus 5.5 --- crates/cli/src/cli.rs | 29 ++++++++ crates/cli/src/commands/mod.rs | 1 + crates/cli/src/commands/site.rs | 115 ++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 1 + crates/cli/src/ui/render.rs | 27 ++++++- crates/sigit-si-api/src/lib.rs | 2 + crates/sigit-si-api/src/models.rs | 16 +++++ crates/sigit-si-api/src/sites.rs | 75 +++++++++++++++++++ 8 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 crates/cli/src/commands/site.rs create mode 100644 crates/sigit-si-api/src/sites.rs diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index e75809a..b7cafd6 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -46,6 +46,10 @@ pub enum Command { #[command(subcommand)] Run(RunCommand), + /// Publish a repository's default branch as a static site. + #[command(subcommand)] + Site(SiteCommand), + /// Report and read commit statuses, the checks CI shows on a commit. #[command(subcommand)] Status(StatusCommand), @@ -248,6 +252,31 @@ pub enum RunCommand { Handoff { id: i64 }, } +#[derive(clap::Subcommand)] +pub enum SiteCommand { + /// Show a repository's site: its address and whether it is live. + View { + #[arg(long)] + repo: Option, + }, + /// Publish the default branch, or publish it again. + Publish { + /// Wait until the site is live or the publish has failed. + #[arg(long)] + wait: bool, + #[arg(long)] + repo: Option, + }, + /// Take the site offline. + Unpublish { + #[arg(long)] + repo: Option, + /// Skip the confirmation prompt. + #[arg(short, long)] + yes: bool, + }, +} + #[derive(clap::Subcommand)] pub enum StatusCommand { /// Report a status on a commit. diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 6791be9..4fd5019 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -10,4 +10,5 @@ pub mod pr; pub mod repo; pub mod run; pub mod session; +pub mod site; pub mod status; diff --git a/crates/cli/src/commands/site.rs b/crates/cli/src/commands/site.rs new file mode 100644 index 0000000..865a103 --- /dev/null +++ b/crates/cli/src/commands/site.rs @@ -0,0 +1,115 @@ +use { + crate::{ + cli::SiteCommand, + context::Ctx, + ui::{self, prompt}, + }, + anyhow::{anyhow, Result}, + sigit_si_api::models::Site, + std::time::Duration, +}; + +/// How often `publish --wait` asks, and how many times: about 90 seconds. +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const POLL_ATTEMPTS: u32 = 45; + +pub async fn run(ctx: &Ctx, command: SiteCommand) -> Result<()> { + ctx.require_token()?; + + match command { + SiteCommand::View { repo } => view(ctx, repo).await, + SiteCommand::Publish { wait, repo } => publish(ctx, wait, repo).await, + SiteCommand::Unpublish { repo, yes } => unpublish(ctx, repo, yes).await, + } +} + +async fn view(ctx: &Ctx, repo: Option) -> Result<()> { + let slug = ctx.resolve_repo(repo)?; + let site = ctx.client.site(&slug.owner, &slug.name).await?; + ui::emit(ctx.json, &site, ui::render::site) +} + +async fn publish(ctx: &Ctx, wait: bool, repo: Option) -> Result<()> { + let slug = ctx.resolve_repo(repo)?; + let mut site = ctx.client.publish_site(&slug.owner, &slug.name).await?; + + if wait { + ui::note("Publishing…"); + for _ in 0..POLL_ATTEMPTS { + if is_settled(&site) { + break; + } + tokio::time::sleep(POLL_INTERVAL).await; + site = ctx.client.site(&slug.owner, &slug.name).await?; + } + if !is_settled(&site) { + return Err(anyhow!( + "The site is still publishing. Check on it with `si site view`." + )); + } + } + + let failed = site.state == "failed"; + ui::emit(ctx.json, &site, |site| { + if failed { + ui::render::site(site); + } else if site.state == "live" { + ui::success(format!("Published {}.", ui::highlight(&site.url))); + } else { + ui::success(format!("Publishing {}.", ui::highlight(&site.url))); + ui::note("Check on it with `si site view`."); + } + })?; + + if failed { + return Err(anyhow!( + "Publishing failed: {}", + site.error.as_deref().unwrap_or("no reason given.") + )); + } + Ok(()) +} + +async fn unpublish(ctx: &Ctx, repo: Option, yes: bool) -> Result<()> { + let slug = ctx.resolve_repo(repo)?; + if !yes && !prompt::confirm(&format!("Take the site for {slug} offline?"), false)? { + ui::note("Cancelled."); + return Ok(()); + } + + ctx.client.unpublish_site(&slug.owner, &slug.name).await?; + ui::success(format!("Taking the site for {slug} offline.")); + Ok(()) +} + +fn is_settled(site: &Site) -> bool { + matches!(site.state.as_str(), "live" | "failed") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn site(state: &str) -> Site { + serde_json::from_value(serde_json::json!({ + "url": "https://demo.example", + "state": state, + "error": null, + "last_published_at": null, + "files": 0, + "bytes": 0, + })) + .unwrap() + } + + #[test] + fn live_and_failed_end_the_wait() { + assert!(is_settled(&site("live"))); + assert!(is_settled(&site("failed"))); + } + + #[test] + fn publishing_keeps_the_wait_going() { + assert!(!is_settled(&site("publishing"))); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7126295..d79551b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -47,6 +47,7 @@ async fn run() -> Result<()> { Command::Issue(command) => commands::issue::run(&ctx, command).await, Command::Code(command) => commands::code::run(&ctx, command).await, Command::Run(command) => commands::run::run(&ctx, command).await, + Command::Site(command) => commands::site::run(&ctx, command).await, Command::Status(command) => commands::status::run(&ctx, command).await, Command::Hook(command) => commands::hook::run(&ctx, command).await, Command::Session(command) => commands::session::run(&ctx, command).await, diff --git a/crates/cli/src/ui/render.rs b/crates/cli/src/ui/render.rs index 9b503af..8c4db94 100644 --- a/crates/cli/src/ui/render.rs +++ b/crates/cli/src/ui/render.rs @@ -6,7 +6,7 @@ use { console::style, sigit_si_api::models::{ AgentRun, Billing, CloudSession, CodeHit, CombinedStatus, Comment, Hook, Issue, - IssueSummary, PullRequest, PullRequestSummary, Repository, RepositorySummary, User, + IssueSummary, PullRequest, PullRequestSummary, Repository, RepositorySummary, Site, User, }, tabled::{builder::Builder, settings::Style}, }; @@ -468,6 +468,31 @@ pub fn agent_run_detail(run: &AgentRun) { } } +// ── Sites ─────────────────────────────────────────────────────────────────── + +pub fn site(site: &Site) { + let state = match site.state.as_str() { + "live" => style("live").green().to_string(), + "failed" => style("failed").red().to_string(), + "publishing" => style("publishing").yellow().to_string(), + other => style(other).dim().to_string(), + }; + println!(); + println!(" {} {}", highlight(&site.url), state); + field("Error", site.error.as_deref()); + field( + "Published", + site.last_published_at.map(relative_time).as_deref(), + ); + if site.files > 0 { + field( + "Size", + Some(&format!("{} files, {} bytes", site.files, site.bytes)), + ); + } + println!(); +} + // ── Billing ───────────────────────────────────────────────────────────────── pub fn billing(billing: &Billing) { diff --git a/crates/sigit-si-api/src/lib.rs b/crates/sigit-si-api/src/lib.rs index ff5062e..65aa315 100644 --- a/crates/sigit-si-api/src/lib.rs +++ b/crates/sigit-si-api/src/lib.rs @@ -56,6 +56,8 @@ pub mod repos; #[cfg(feature = "client")] pub mod sessions; #[cfg(feature = "client")] +pub mod sites; +#[cfg(feature = "client")] pub mod statuses; /// Request header a client uses to name itself. [`client::ClientBuilder::client_name`] diff --git a/crates/sigit-si-api/src/models.rs b/crates/sigit-si-api/src/models.rs index b7767aa..ab70f06 100644 --- a/crates/sigit-si-api/src/models.rs +++ b/crates/sigit-si-api/src/models.rs @@ -194,6 +194,22 @@ pub struct AgentRun { pub transcript_tail: Option, } +// ── Sites ─────────────────────────────────────────────────────────────────── + +/// A repository's published static site. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Site { + pub url: String, + /// `publishing`, `live` or `failed`. + pub state: String, + pub error: Option, + pub last_published_at: Option>, + #[serde(default)] + pub files: i64, + #[serde(default)] + pub bytes: i64, +} + // ── MCP-backed resources ──────────────────────────────────────────────────── // // Issues, pull requests, and code search have no REST surface; they are served diff --git a/crates/sigit-si-api/src/sites.rs b/crates/sigit-si-api/src/sites.rs new file mode 100644 index 0000000..6cc3fb8 --- /dev/null +++ b/crates/sigit-si-api/src/sites.rs @@ -0,0 +1,75 @@ +//! A repository's default branch, published as a static site. + +use { + crate::{client::Client, error::Result, models::Site}, + reqwest::{Method, RequestBuilder}, +}; + +impl Client { + /// `GET /api/v1/repos/:owner/:repo/site` — `404` when the repository has no + /// site. + pub async fn site(&self, owner: &str, repo: &str) -> Result { + self.get(&site_path(owner, repo)).await + } + + /// `POST /api/v1/repos/:owner/:repo/site` — publishes the site, or + /// publishes it again. Owner only. Publishing runs in the background, so + /// the answer has state `publishing`; poll [`Client::site`] for `live` or + /// `failed`. Publishing too often answers `429`. + pub async fn publish_site(&self, owner: &str, repo: &str) -> Result { + self.send(self.publish_site_request(owner, repo)).await + } + + /// `DELETE /api/v1/repos/:owner/:repo/site` — takes the site offline. The + /// server does it in the background. + pub async fn unpublish_site(&self, owner: &str, repo: &str) -> Result<()> { + self.delete(&site_path(owner, repo)).await + } + + fn publish_site_request(&self, owner: &str, repo: &str) -> RequestBuilder { + self.request(Method::POST, &site_path(owner, repo)) + .json(&serde_json::json!({})) + } +} + +fn site_path(owner: &str, repo: &str) -> String { + format!("repos/{owner}/{repo}/site") +} + +#[cfg(test)] +mod tests { + use {super::*, crate::Environment}; + + #[test] + fn publishing_posts_to_the_site_path() { + let client = Client::builder(Environment::Production) + .base_url("https://sigit.si") + .token("tok") + .build() + .unwrap(); + let request = client.publish_site_request("seto", "demo").build().unwrap(); + + assert_eq!(request.method(), Method::POST); + assert_eq!( + request.url().as_str(), + "https://sigit.si/api/v1/repos/seto/demo/site" + ); + } + + #[test] + fn a_site_parses() { + let body = r#"{"url":"https://demo.example","state":"live","error":null, + "last_published_at":"2026-10-01T10:00:00Z","files":4,"bytes":2048}"#; + let site: Site = serde_json::from_str(body).unwrap(); + assert_eq!(site.state, "live"); + assert_eq!(site.files, 4); + } + + #[test] + fn a_site_still_publishing_has_no_timestamp_yet() { + let body = r#"{"url":"https://demo.example","state":"publishing","error":null, + "last_published_at":null,"files":0,"bytes":0}"#; + let site: Site = serde_json::from_str(body).unwrap(); + assert!(site.last_published_at.is_none()); + } +}