Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions crates/rowbit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod grants;
mod place;
mod review;
mod setup;
mod status;
mod term;

use std::path::PathBuf;
Expand Down Expand Up @@ -198,6 +199,12 @@ enum Command {
/// write, connects the agent clients you pick and sets the review
/// page's password. Keeps its files in ~/.rowbit/<name>.
Setup(setup::SetupArgs),
/// What Rowbit is connected to: each setup's database, review page,
/// agents and where they're connected
///
/// Lists every setup in ~/.rowbit and the demo, or with --dir (or in a
/// folder of Rowbit's) that one. Reads files only.
Status(status::StatusArgs),
}

/// Only Rowbit's state file: for commands that don't touch the database,
Expand Down Expand Up @@ -394,7 +401,29 @@ async fn serve(
engine.set_review_address(&address)?;
let engine = std::sync::Arc::new(engine);
tokio::spawn(rowbit_web::notify::Notifier::new(engine.clone(), config.notify.clone()).run());
let setup_json = serde_json::json!({
"database": database_url(&setup.url)
.ok()
.and_then(|u| rowbit_engine::overview::Database::from_url(&u)),
"dir": setup
.policy
.canonicalize()
.ok()
.and_then(|p| p.parent().map(place::tilde)),
"review_page": format!("{}/", address.trim_end_matches('/')),
"mcp": config.mcp_http.then(|| format!("{}/mcp", address.trim_end_matches('/'))),
"sign_in": match &sign_in {
rowbit_web::SignIn::Oidc(_) => format!(
"through {}",
config.oidc.as_ref().map(|o| o.issuer.as_str()).unwrap_or("an identity provider")
),
_ => "page password, this machine only".to_owned(),
},
"notify": config.notify.iter().map(|n| n.name.clone()).collect::<Vec<_>>(),
"version": env!("CARGO_PKG_VERSION"),
});
let web = rowbit_web::Web {
setup: setup_json,
sign_in,
hosts: config.public_host().into_iter().collect(),
secure: config.https(),
Expand Down Expand Up @@ -494,7 +523,7 @@ fn place_files(command: &mut Command) -> anyhow::Result<()> {
command: GrantsCommand::Diff { admin } | GrantsCommand::Apply { admin, .. },
} => admin.place()?,
Command::Connect(args) => args.place(),
Command::Demo(_) | Command::Setup(_) | Command::Ping { .. } => {}
Command::Demo(_) | Command::Setup(_) | Command::Ping { .. } | Command::Status(_) => {}
}
Ok(())
}
Expand Down Expand Up @@ -867,8 +896,9 @@ async fn main() -> anyhow::Result<()> {
println!("{agent}");
if let Some((created, last)) = manual {
println!(
" {:<10} single token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}",
" {:<10} {}: made {}, {}",
"--single",
rowbit_engine::overview::OWN_TOKEN,
review::when(created),
used(last)
);
Expand Down Expand Up @@ -930,6 +960,7 @@ async fn main() -> anyhow::Result<()> {
connect::run(args)?;
}
Command::Setup(args) => setup::run(args).await?,
Command::Status(args) => status::run(args)?,
Command::Ping { url } => {
let adapter = rowbit_mysql::MySqlAdapter::new(&database_url(&url)?)?;
let info = adapter.server_info().await?;
Expand Down
9 changes: 8 additions & 1 deletion crates/rowbit-cli/src/place.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ pub fn init(args: Vec<OsString>) -> anyhow::Result<Vec<OsString>> {
{
None
}
// `status` lists every setup unless told which.
None if command.as_deref() == Some("status") => None,
None => {
let found = home().map(|h| setups(&h)).unwrap_or_default();
match pick(&found) {
Expand Down Expand Up @@ -292,7 +294,12 @@ pub fn file(path: &mut PathBuf, default: &str) {
/// `ROWBIT_DATABASE_URL` from the directory's `env` file, for when neither
/// the flag nor the environment set it.
pub fn database_url() -> anyhow::Result<Option<String>> {
let Some(dir) = dir() else { return Ok(None) };
// Run inside a setup's or the demo's folder, that folder.
let dir = match dir() {
Some(dir) => dir,
None if Path::new("rowbit.yml").exists() => Path::new("."),
None => return Ok(None),
};
Ok(read_env_file(&dir.join(ENV_FILE))?.or_else(|| crate::demo::database_url(dir)))
}

Expand Down
176 changes: 176 additions & 0 deletions crates/rowbit-cli/src/status.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! `rowbit status`: every setup on this machine, what database each
//! guards, whether its review page is up, and where its agents are
//! connected. Reads files, opening state files read-only, and never
//! connects to a database. See "What Rowbit is connected to" in
//! docs/spec/setup.md.

use std::path::{Path, PathBuf};

use rowbit_engine::overview::{self, Database};

use crate::place::{self, Found};

#[derive(clap::Args)]
pub struct StatusArgs {}

pub fn run(_: StatusArgs) -> anyhow::Result<()> {
let found = setups()?;
if found.is_empty() {
println!("No Rowbit setup on this machine yet. Set one up with: rowbit setup");
return Ok(());
}
let width = found.iter().map(|f| f.name.len()).max().unwrap_or(0);
for (i, f) in found.iter().enumerate() {
if i > 0 {
println!();
}
for (j, line) in describe(f).iter().enumerate() {
let name = if j == 0 { f.name.as_str() } else { "" };
println!("{name:<width$} {line}");
}
}
Ok(())
}

/// With `--dir` (or in a folder of Rowbit's), that setup only; otherwise
/// every one `place` knows about.
fn setups() -> anyhow::Result<Vec<Found>> {
let here = match place::dir() {
Some(d) => Some(d.to_owned()),
None if Path::new("rowbit.yml").exists() => Some(std::env::current_dir()?),
None => None,
};
if let Some(dir) = here {
let dir = dir.canonicalize().unwrap_or(dir);
let name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("here")
.to_owned();
return Ok(vec![Found { name, dir }]);
}
Ok(place::home().map(|h| place::setups(&h)).unwrap_or_default())
}

/// The lines `rowbit status` prints for one setup.
fn describe(f: &Found) -> Vec<String> {
let dir = &f.dir;
let mut lines = vec![place::tilde(dir)];
lines.push(match database_url(dir) {
Ok(Some(url)) => match Database::from_url(&url) {
Some(db) => format!("database: {}", db.line()),
None => "database: the URL in the env file doesn't parse".into(),
},
Ok(None) => "database: no URL (no env file)".into(),
Err(e) => format!("database: {e:#}"),
});
let address = server_address(dir);
lines.push(format!(
"review page: {}, {}{}",
crate::term::link(&format!("{}/", address.base().trim_end_matches('/'))),
if rowbit_answers(address.listen) {
// Some Rowbit: two setups on one address can't tell which.
"a Rowbit answers there"
} else {
"not running"
},
match held(dir) {
Ok(Some(0)) => ", nothing held".to_owned(),
Ok(Some(n)) => format!(", {n} held"),
Ok(None) => String::new(),
Err(e) => format!(", couldn't read rowbit.db: {e:#}"),
}
));
let mut connected = false;
match agents(dir) {
Ok(agents) => {
for a in agents {
lines.push(format!("agent {}: {}", a.id, a.rules_line()));
if a.connections.is_empty() {
lines.push(
" not connected over HTTP; `rowbit connect <client>` connects it".into(),
);
}
for c in a.connections {
connected = true;
lines.push(format!(
" {:<10} {} ({})",
c.id,
c.label,
c.used_ms
.map(|u| format!("last used {}", crate::review::when(u)))
.unwrap_or_else(|| "never used".into())
));
}
}
}
Err(e) => lines.push(format!("agents: {e:#}")),
}
if connected {
lines.push(format!(
"sign one out: rowbit agents revoke <agent> --connection <id> (or --single) --dir {}",
crate::connect::shell_quote(&dir.display().to_string())
));
}
lines
}

/// Whether a Rowbit review page answers at `addr`: its sign-in page, not
/// just anything listening.
fn rowbit_answers(addr: std::net::SocketAddr) -> bool {
use std::io::{Read, Write};
let timeout = std::time::Duration::from_millis(300);
let Ok(mut s) = std::net::TcpStream::connect_timeout(&addr, timeout) else {
return false;
};
let _ = s.set_read_timeout(Some(timeout));
let _ = s.set_write_timeout(Some(timeout));
if write!(s, "GET /login HTTP/1.0\r\nHost: {addr}\r\n\r\n").is_err() {
return false;
}
let mut body = Vec::new();
let _ = s.take(16 * 1024).read_to_end(&mut body);
String::from_utf8_lossy(&body).contains("Sign in to Rowbit")
}

fn database_url(dir: &Path) -> anyhow::Result<Option<String>> {
Ok(
place::read_env_file(&dir.join(place::ENV_FILE))?
.or_else(|| crate::demo::database_url(dir)),
)
}

fn server_address(dir: &Path) -> rowbit_web::config::Address {
std::fs::read_to_string(dir.join("rowbit-server.yml"))
.ok()
.and_then(|src| rowbit_web::config::ServerConfig::address(&src).ok())
.unwrap_or_default()
}

/// The setup's state file, read-only; `None` if it has none yet.
fn store(dir: &Path) -> anyhow::Result<Option<rowbit_store::Store>> {
let path: PathBuf = dir.join("rowbit.db");
if !path.exists() {
return Ok(None);
}
Ok(Some(rowbit_store::Store::open_read_only(&path)?))
}

fn held(dir: &Path) -> anyhow::Result<Option<usize>> {
let Some(store) = store(dir)? else {
return Ok(None);
};
Ok(Some(store.list(rowbit_core::change::State::Held)?.len()))
}

fn agents(dir: &Path) -> anyhow::Result<Vec<overview::Agent>> {
let src = std::fs::read_to_string(dir.join("rowbit.yml"))?;
let policy = rowbit_policy::PolicyFile::from_yaml(&src)?;
// A setup that has never run has no tokens; one whose state can't be
// read says so, rather than claiming nobody is connected.
let store = match store(dir).map_err(|e| anyhow::anyhow!("couldn't read rowbit.db: {e:#}"))? {
Some(s) => s,
None => rowbit_store::Store::open_in_memory()?,
};
Ok(overview::agents(&policy, &store)?)
}
12 changes: 12 additions & 0 deletions crates/rowbit-cli/tests/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,18 @@ fn a_project_folder_finds_the_setup_in_rowbit_home() {
assert!(agents.contains("coding-agent no token"), "{agents}");
let (ok, out) = in_app(&["connect", "claude-code", "--force"]);
assert!(ok, "{out}");
// `rowbit status` says what it guards, without the password.
let (ok, status) = in_app(&["status"]);
assert!(ok, "{status}");
assert!(
status.contains("shop ~/.rowbit/shop")
&& status.contains("database: shop on h:3306 as u, no TLS")
&& status.contains("not running")
&& status.contains("agent coding-agent: ")
&& status.contains(" Claude Code in "),
"{status}"
);
assert!(!status.contains(":p@"), "{status}");

// Two, and neither the default: Rowbit asks which.
std::fs::create_dir_all(c.home.join(".rowbit/other")).unwrap();
Expand Down
8 changes: 8 additions & 0 deletions crates/rowbit-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ use rowbit_policy::{
};
use rowbit_store::{AuditEvent, Change, ChangeId, NewDelivery, Review, Store, StoreError};
pub use rowbit_store::{Delivery, DeliveryOutcome};

pub mod overview;
use thiserror::Error;

#[derive(Debug, Error)]
Expand Down Expand Up @@ -558,6 +560,12 @@ impl Engine {
Ok(agent.filter(|a| self.policy.agents.contains_key(a)))
}

/// Each agent in `rowbit.yml`, its rules in brief and where it is
/// connected.
pub fn agents_overview(&self) -> Result<Vec<overview::Agent>, EngineError> {
Ok(overview::agents(&self.policy, &self.store())?)
}

/// Checks the whole audit log; see [`Store::verify`].
pub fn verify(&self) -> Result<Result<(), rowbit_store::Tampered>, EngineError> {
Ok(self.store().verify()?)
Expand Down
Loading
Loading