Add super basic WASM component management for first-party components (currently only auth-ui) to the CLI.

Something like this could involve into a plugin eco-system but that is a long way. Also this is not a package/dependency manager, there's no notion of transitivity etc.
This commit is contained in:
Sebastian Jeltsch
2025-09-22 15:35:55 +02:00
parent 80519668ef
commit d3db3282b1
9 changed files with 259 additions and 26 deletions

20
Cargo.lock generated
View File

@@ -7999,8 +7999,11 @@ dependencies = [
"chrono",
"clap",
"env_logger",
"itertools 0.14.0",
"log",
"mimalloc",
"minijinja",
"reqwest",
"serde",
"serde_json",
"tokio",
@@ -8010,6 +8013,7 @@ dependencies = [
"utoipa",
"utoipa-swagger-ui",
"uuid",
"zip 5.1.1",
]
[[package]]
@@ -8529,7 +8533,7 @@ dependencies = [
"serde_json",
"url",
"utoipa",
"zip",
"zip 3.0.0",
]
[[package]]
@@ -10060,6 +10064,20 @@ dependencies = [
"zopfli",
]
[[package]]
name = "zip"
version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.11.4",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.5.2"

View File

@@ -75,7 +75,9 @@ askama = { version = "0.14.0", default-features = false, features = ["derive", "
axum = { version = "^0.8.1", features = ["multipart"] }
env_logger = { version = "^0.11.8", default-features = false, features = ["auto-color", "humantime"] }
libsqlite3-sys = { version = "0.35.0", default-features = false, features = ["bundled", "preupdate_hook"] }
minijinja = { version = "2.1.2", default-features = false }
parking_lot = { version = "0.12.3", default-features = false, features = ["send_guard", "arc_lock"] }
reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls", "json"] }
rusqlite = { version = "0.37.0", default-features = false, features = ["bundled", "column_decltype", "functions", "backup", "preupdate_hook"] }
rust-embed = { version = "8.4.0", default-features = false, features = ["mime-guess"] }
tokio = { version = "^1.38.0", default-features = false, features = ["macros", "net", "rt-multi-thread", "fs", "signal", "time", "sync"] }

View File

@@ -77,6 +77,12 @@ pub struct GitVersion {
pub commits_since: Option<u16>,
}
impl GitVersion {
pub fn tag(&self) -> String {
return format!("v{}.{}.{}", self.major, self.minor, self.patch);
}
}
#[derive(Clone, Debug, Default)]
pub struct VersionInfo {
/// Name of the crate as defined in its Cargo.toml.

View File

@@ -16,17 +16,21 @@ axum = { version = "^0.8.1", features=["multipart"] }
chrono = "^0.4.38"
clap = { version = "^4.4.11", features=["derive", "env"] }
env_logger = { workspace = true }
trailbase = { workspace = true }
trailbase-build = { workspace = true }
itertools = "0.14.0"
log = "^0.4.21"
mimalloc = { version = "^0.1.41", default-features = false }
minijinja = { workspace = true }
reqwest = { workspace = true }
serde = { version = "^1.0.203", features = ["derive"] }
serde_json = "^1.0.117"
tokio = { workspace = true }
trailbase = { workspace = true }
trailbase-build = { workspace = true }
url = "2.5.4"
utoipa = { version = "5.0.0-beta.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true }
uuid = { workspace = true }
url = "2.5.4"
zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[build-dependencies]
trailbase-build = { workspace = true }

View File

@@ -77,6 +77,11 @@ pub enum SubCommands {
},
/// Programmatically send emails.
Email(EmailArgs),
/// Manage WASM components
Components {
#[command(subcommand)]
cmd: Option<ComponentSubCommands>,
},
}
#[derive(Args, Clone, Debug)]
@@ -220,3 +225,56 @@ pub enum UserSubCommands {
user: String,
},
}
#[derive(Clone, Debug)]
pub enum ComponentReference {
Path(std::path::PathBuf),
Url(url::Url),
Name(String),
}
impl TryFrom<&str> for ComponentReference {
type Error = String;
fn try_from(reference: &str) -> Result<Self, Self::Error> {
if let Ok(url) = url::Url::parse(reference) {
if url.scheme() != "https" {
return Err("Only HTTPS supported".into());
}
return Ok(ComponentReference::Url(url));
}
let path = std::path::PathBuf::from(reference);
if let Some(ext) = path.extension() {
match &*ext.to_string_lossy() {
"wasm" | "zip" => {
return Ok(ComponentReference::Path(path));
}
_ => {}
}
}
if reference
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/')
{
return Ok(ComponentReference::Name(reference.into()));
}
return Err("Failed to parse component reference".into());
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum ComponentSubCommands {
/// Add new WASM component.
Add {
/// File-system path or url for wasm component to install.
reference: String,
},
/// Remove/delete WASM component.
Remove { reference: String },
/// List first-party components.
List,
}

View File

@@ -5,8 +5,14 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use chrono::TimeZone;
use clap::{CommandFactory, Parser};
use itertools::Itertools;
use minijinja::{Environment, context};
use serde::Deserialize;
use std::rc::Rc;
use std::{
collections::HashMap,
io::{Read, Seek},
rc::Rc,
};
use tokio::{fs, io::AsyncWriteExt};
use trailbase::{
DataDir, Server, ServerOptions,
@@ -16,7 +22,8 @@ use trailbase::{
use utoipa::OpenApi;
use trailbase_cli::{
AdminSubCommands, DefaultCommandLineArgs, OpenApiSubCommands, SubCommands, UserSubCommands,
AdminSubCommands, ComponentReference, ComponentSubCommands, DefaultCommandLineArgs,
OpenApiSubCommands, SubCommands, UserSubCommands,
};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
@@ -246,12 +253,87 @@ async fn async_main() -> Result<(), BoxError> {
}
};
}
Some(SubCommands::Components { cmd }) => {
init_logger(false);
match cmd {
Some(ComponentSubCommands::Add { reference }) => {
match ComponentReference::try_from(reference.as_str())? {
ComponentReference::Name(name) => {
let component_def = find_component(&name)?;
let version = trailbase_build::get_version_info!();
let Some(git_version) = version.git_version() else {
return Err("missing version".into());
};
let env = Environment::empty();
let url_str = env
.template_from_named_str("url", &component_def.url_template)?
.render(context! {
release => git_version.tag(),
})?;
let url = url::Url::parse(&url_str)?;
log::info!("Downloading {url}");
let bytes = reqwest::get(url.clone()).await?.bytes().await?;
install_wasm_component(&data_dir, url.path(), std::io::Cursor::new(bytes)).await?;
}
ComponentReference::Url(url) => {
log::info!("Downloading {url}");
let bytes = reqwest::get(url.clone()).await?.bytes().await?;
install_wasm_component(&data_dir, url.path(), std::io::Cursor::new(bytes)).await?;
}
ComponentReference::Path(path) => {
let bytes = std::fs::read(&path)?;
install_wasm_component(&data_dir, &path, std::io::Cursor::new(bytes)).await?;
}
}
}
Some(ComponentSubCommands::Remove { reference }) => {
match ComponentReference::try_from(reference.as_str())? {
ComponentReference::Url(_) => {
return Err("URLs not supported for component removal".into());
}
ComponentReference::Name(name) => {
let component_def = find_component(&name)?;
let wasm_dir = data_dir.root().join("wasm");
let filenames: Vec<_> = component_def
.wasm_filenames
.into_iter()
.map(|f| wasm_dir.join(f))
.collect();
for filename in &filenames {
std::fs::remove_file(filename)?;
}
println!("Removed: {filenames:?}");
}
ComponentReference::Path(path) => {
std::fs::remove_file(&path)?;
println!("Removed: {path:?}");
}
}
}
Some(ComponentSubCommands::List) => {
println!("Components:\n\n{}", repo().keys().join("\n"));
}
_ => {
DefaultCommandLineArgs::command()
.find_subcommand_mut("component")
.map(|cmd| cmd.print_help());
}
};
}
None => {
let _ = DefaultCommandLineArgs::command().print_help();
}
}
Ok(())
return Ok(());
}
fn to_user_reference(user: String) -> api::cli::UserReference {
@@ -261,6 +343,81 @@ fn to_user_reference(user: String) -> api::cli::UserReference {
return api::cli::UserReference::Id(user);
}
#[derive(Debug, Clone)]
struct ComponentDefinition {
url_template: String,
wasm_filenames: Vec<String>,
}
fn repo() -> HashMap<String, ComponentDefinition> {
return HashMap::from([
("trailbase/auth_ui".to_string(), ComponentDefinition {
url_template: "https://github.com/trailbaseio/trailbase/releases/download/{{ release }}/trailbase_{{ release }}_wasm_auth_ui.zip".to_string(),
wasm_filenames: vec!["auth_ui_component.wasm".to_string()],
})
]);
}
fn find_component(name: &str) -> Result<ComponentDefinition, BoxError> {
return repo()
.get(name)
.cloned()
.ok_or_else(|| "component not found".into());
}
async fn install_wasm_component(
data_dir: &DataDir,
path: impl AsRef<std::path::Path>,
mut reader: impl Read + Seek,
) -> Result<(), BoxError> {
let path = path.as_ref();
let wasm_dir = data_dir.root().join("wasm");
match path
.extension()
.map(|p| p.to_string_lossy().to_string())
.as_deref()
{
Some("zip") => {
let mut archive = zip::ZipArchive::new(reader)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if let Some(path) = file.enclosed_name() {
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
continue;
}
let Some(filename) = path.file_name().and_then(|e| e.to_str()) else {
return Err(format!("Invalid filename: {:?}", file.name()).into());
};
let component_file_path = wasm_dir.join(filename);
let mut component_file = std::fs::File::create(&component_file_path)?;
std::io::copy(&mut file, &mut component_file)?;
println!("Added: {component_file_path:?}");
}
}
}
Some("wasm") => {
let Some(filename) = path.file_name().and_then(|e| e.to_str()) else {
return Err(format!("Invalid filename: {path:?}").into());
};
let component_file_path = wasm_dir.join(filename);
let mut component_file = std::fs::File::create(&component_file_path)?;
std::io::copy(&mut reader, &mut component_file)?;
println!("Added: {component_file_path:?}");
}
_ => {
return Err("unexpected format".into());
}
}
return Ok(());
}
fn main() -> Result<(), BoxError> {
let runtime = Rc::new(
tokio::runtime::Builder::new_multi_thread()

View File

@@ -3,8 +3,8 @@
mod args;
pub use args::{
AdminSubCommands, DefaultCommandLineArgs, EmailArgs, JsonSchemaModeArg, SubCommands,
UserSubCommands,
AdminSubCommands, ComponentReference, ComponentSubCommands, DefaultCommandLineArgs, EmailArgs,
JsonSchemaModeArg, SubCommands, UserSubCommands,
};
pub use args::OpenApiSubCommands;

View File

@@ -53,7 +53,7 @@ lazy_static = "1.4.0"
lettre = { version = "^0.11.7", default-features = false, features = ["tokio1-rustls-tls", "sendmail-transport", "smtp-transport", "builder"] }
log = { version = "^0.4.21", default-features = false }
mini-moka = "0.10.3"
minijinja = { version = "2.1.2", default-features = false }
minijinja = { workspace = true }
oauth2 = { version = "5.0.0-alpha.4", default-features = false, features = ["reqwest", "rustls-tls"] }
object_store = { version = "0.12.0", default-features = false, features = ["aws", "fs"] }
parking_lot = { workspace = true }
@@ -63,7 +63,7 @@ prost-reflect = { version = "^0.16.0", default-features = false, features = ["de
rand = "^0.9.0"
reactivate = { version = "0.4.2", features = ["threadsafe"] }
regex = "1.11.0"
reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls", "json"] }
reqwest = { workspace = true }
rusqlite = { workspace = true }
rust-embed = { workspace = true }
serde = { version = "^1.0.203", features = ["derive"] }

View File

@@ -1,6 +1,5 @@
use axum::{Json, extract::State};
use serde::Serialize;
use trailbase_build::version::GitVersion;
use ts_rs::TS;
use crate::admin::AdminError as Error;
@@ -26,20 +25,9 @@ pub async fn info_handler(State(state): State<AppState>) -> Result<Json<InfoResp
fn build_info_response(state: &AppState) -> InfoResponse {
let version_info = state.version();
let git_version = version_info.git_version().map(
|GitVersion {
major,
minor,
patch,
commits_since,
..
}| {
(
format!("v{major}.{minor}.{patch}"),
commits_since.unwrap_or(0) as usize,
)
},
);
let git_version = version_info
.git_version()
.map(|v| (v.tag(), v.commits_since.unwrap_or(0) as usize));
return InfoResponse {
compiler: version_info.host_compiler,