mod commands;
use anyhow::{Context, Result};
use clap::{Subcommand, ValueEnum};
use sqlx::PgPool;
use uuid::Uuid;
use yorishiro_core::repositories::tenancy::{self, MembershipRole};
use yorishiro_core::services::auth::ApiKeyScope;
use commands::{create_api_key, list_api_keys, resync_embeddings, revoke_api_key};
#[derive(Subcommand)]
pub enum AdminCommand {
CreateTenant {
name: String,
#[arg(long)]
max_workspaces: Option<i32>,
},
ListTenants,
CreateWorkspace {
tenant_id: Uuid,
name: String,
#[arg(long)]
max_entities: Option<i32>,
},
ListWorkspaces { tenant_id: Uuid },
CreateUser {
email: String,
password: String,
#[arg(long)]
display_name: Option<String>,
},
AddMember {
tenant_id: Uuid,
user_id: Uuid,
role: RoleArg,
},
ListMembers { tenant_id: Uuid },
CreateInvite {
tenant_id: Uuid,
email: String,
role: RoleArg,
#[arg(long, default_value_t = 168)]
ttl_hours: i64,
},
CreateApiKey {
workspace_id: Uuid,
scope: ScopeArg,
#[arg(long)]
user: Option<Uuid>,
},
ListApiKeys { workspace_id: Uuid },
RevokeApiKey { key_id: Uuid },
ResyncEmbeddings { workspace_id: Uuid },
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum ScopeArg {
Read,
Write,
Schema,
}
impl From<ScopeArg> for ApiKeyScope {
fn from(value: ScopeArg) -> Self {
match value {
ScopeArg::Read => ApiKeyScope::Read,
ScopeArg::Write => ApiKeyScope::Write,
ScopeArg::Schema => ApiKeyScope::Schema,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum RoleArg {
Owner,
Admin,
Member,
Viewer,
}
impl From<RoleArg> for MembershipRole {
fn from(value: RoleArg) -> Self {
match value {
RoleArg::Owner => MembershipRole::Owner,
RoleArg::Admin => MembershipRole::Admin,
RoleArg::Member => MembershipRole::Member,
RoleArg::Viewer => MembershipRole::Viewer,
}
}
}
pub async fn run(command: AdminCommand) -> Result<()> {
let database_url =
std::env::var("DATABASE_URL").context("DATABASE_URL must be set for admin commands")?;
let pool = PgPool::connect(&database_url)
.await
.context("failed to connect to database")?;
sqlx::migrate!("../../migrations").run(&pool).await?;
match command {
AdminCommand::CreateTenant {
name,
max_workspaces,
} => {
let tenant = tenancy::create_tenant(&pool, &name, max_workspaces).await?;
let workspace = tenancy::create_workspace(&pool, tenant.id, "default", None).await?;
println!("tenant created");
println!(" id: {}", tenant.id);
println!(" name: {}", tenant.name);
println!(" max_workspaces: {}", format_limit(tenant.max_workspaces));
println!("default workspace created");
println!(" id: {}", workspace.id);
println!(" name: {}", workspace.name);
}
AdminCommand::ListTenants => {
let tenants = tenancy::list_tenants(&pool).await?;
if tenants.is_empty() {
println!("no tenants (create one with `admin create-tenant <name>`)");
}
for tenant in tenants {
println!(
"{} {:<24} max_workspaces={}",
tenant.id,
tenant.name,
format_limit(tenant.max_workspaces)
);
}
}
AdminCommand::CreateWorkspace {
tenant_id,
name,
max_entities,
} => {
let workspace = tenancy::create_workspace(&pool, tenant_id, &name, max_entities)
.await
.map_err(anyhow::Error::from)?;
println!("workspace created");
println!(" id: {}", workspace.id);
println!(" tenant id: {}", workspace.tenant_id);
println!(" name: {}", workspace.name);
println!(" max_entities: {}", format_limit(workspace.max_entities));
}
AdminCommand::ListWorkspaces { tenant_id } => {
let workspaces = tenancy::list_workspaces(&pool, tenant_id)
.await
.map_err(anyhow::Error::from)?;
if workspaces.is_empty() {
println!("no workspaces for tenant {tenant_id}");
}
for workspace in workspaces {
println!(
"{} {:<24} max_entities={}",
workspace.id,
workspace.name,
format_limit(workspace.max_entities)
);
}
}
AdminCommand::CreateUser {
email,
password,
display_name,
} => {
let user = tenancy::create_user(&pool, &email, &password, display_name.as_deref())
.await
.map_err(anyhow::Error::from)?;
println!("user created");
println!(" id: {}", user.id);
println!(" email: {}", user.email);
}
AdminCommand::AddMember {
tenant_id,
user_id,
role,
} => {
tenancy::add_member(&pool, tenant_id, user_id, role.into())
.await
.map_err(anyhow::Error::from)?;
println!("membership added: user {user_id} is now {role:?} of tenant {tenant_id}");
}
AdminCommand::ListMembers { tenant_id } => {
let members = tenancy::list_members(&pool, tenant_id)
.await
.map_err(anyhow::Error::from)?;
if members.is_empty() {
println!("no members for tenant {tenant_id}");
}
for member in members {
println!("{} {:<8?} {}", member.user_id, member.role, member.email);
}
}
AdminCommand::CreateInvite {
tenant_id,
email,
role,
ttl_hours,
} => {
let (invite, token) = tenancy::create_invite(
&pool,
tenant_id,
&email,
role.into(),
chrono::Duration::hours(ttl_hours),
)
.await
.map_err(anyhow::Error::from)?;
println!("invite created (the plaintext token is shown ONLY once — send it now)");
println!(" token: {token}");
println!(" invite id: {}", invite.id);
println!(" tenant id: {}", invite.tenant_id);
println!(" email: {}", invite.email);
println!(" role: {:?}", invite.role);
println!(
" expires at: {}",
invite.expires_at.format("%Y-%m-%d %H:%M UTC")
);
}
AdminCommand::CreateApiKey {
workspace_id,
scope,
user,
} => {
let scope = ApiKeyScope::from(scope);
let created = create_api_key(&pool, workspace_id, scope, user).await?;
println!("api key created (the plaintext key is shown ONLY once — store it now)");
println!(" key: {}", created.plaintext);
println!(" key id: {}", created.id);
println!(" workspace id: {}", created.workspace_id);
println!(" scope: {scope:?}");
if let Some(user_id) = created.user_id {
println!(" user id: {user_id}");
}
}
AdminCommand::ListApiKeys { workspace_id } => {
let keys = list_api_keys(&pool, workspace_id).await?;
if keys.is_empty() {
println!("no api keys for workspace {workspace_id}");
}
for key in keys {
println!(
"{} {:<8} prefix={} user={} created={} last_used={}",
key.id,
key.scope,
key.key_prefix,
key.user_id
.map(|id| id.to_string())
.unwrap_or_else(|| "-".into()),
key.created_at.format("%Y-%m-%d %H:%M"),
key.last_used_at
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "never".into()),
);
}
}
AdminCommand::RevokeApiKey { key_id } => {
revoke_api_key(&pool, key_id).await?;
println!("api key {key_id} revoked (takes effect on the next request)");
}
AdminCommand::ResyncEmbeddings { workspace_id } => {
let provider = crate::build_embedding_provider()
.context("embedding provider must be configured (see .env.example)")?;
let report = resync_embeddings(&pool, workspace_id, provider.as_ref()).await?;
println!(
"resync finished: {} entities had no embedding, {} synced, {} failed \
(entities whose entity_type has no x-embed field stay without embedding)",
report.candidates, report.synced, report.failed,
);
}
}
Ok(())
}
fn format_limit(limit: Option<i32>) -> String {
match limit {
Some(n) => n.to_string(),
None => "unlimited".to_string(),
}
}