diff --git a/Cargo.lock b/Cargo.lock index faa9a50a..e187c2a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,6 +2331,10 @@ dependencies = [ [[package]] name = "soar-events" version = "0.2.0" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "soar-operations" diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 2528a649..09c2bf47 100644 --- a/crates/soar-cli/src/apply.rs +++ b/crates/soar-cli/src/apply.rs @@ -10,7 +10,10 @@ use tabled::{ }; use tracing::{info, warn}; -use crate::utils::{display_settings, icon_or, Colored, Icons}; +use crate::{ + json_output::{self, ApplyDiffJson}, + utils::{display_settings, event_stream_enabled, icon_or, Colored, Icons}, +}; pub async fn apply_packages( ctx: &SoarContext, @@ -23,7 +26,7 @@ pub async fn apply_packages( let config = PackagesConfig::load(packages_config.as_deref())?; let resolved = config.resolved_packages(); - if resolved.is_empty() { + if resolved.is_empty() && !(dry_run && event_stream_enabled()) { info!("No packages declared in configuration"); return Ok(()); } @@ -32,6 +35,13 @@ pub async fn apply_packages( let diff = apply::compute_diff(ctx, &resolved, prune).await?; + // A dry run is a question rather than an operation, so it answers with the + // diff itself instead of streaming what it would do. + if dry_run && event_stream_enabled() { + json_output::emit(&ApplyDiffJson::new(&diff)); + return Ok(()); + } + display_diff(&diff, prune); if !diff.has_changes() && !diff.has_toml_updates() { diff --git a/crates/soar-cli/src/cli.rs b/crates/soar-cli/src/cli.rs index b3f69bcb..6525412c 100644 --- a/crates/soar-cli/src/cli.rs +++ b/crates/soar-cli/src/cli.rs @@ -313,6 +313,10 @@ pub enum Commands { #[arg(required = false, long, short)] ask: bool, + /// Report what would be updated, without updating anything + #[arg(required = false, long, conflicts_with_all = ["ask", "keep", "no_verify"])] + check: bool, + /// Skip checksum verification #[arg(required = false, long)] no_verify: bool, @@ -487,6 +491,10 @@ pub enum Commands { #[clap(name = "env")] Env, + /// Print how a frontend should drive this soar + #[clap(name = "plugin-manifest")] + PluginManifest, + /// Garbage collection #[clap(name = "clean")] Clean { diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs new file mode 100644 index 00000000..c2ba5754 --- /dev/null +++ b/crates/soar-cli/src/json_output.rs @@ -0,0 +1,287 @@ +//! The shapes `--json` reports for the query commands. +//! +//! Written out rather than derived from the internal models: what a caller can +//! read is a contract, and the models carry fields meant for the installer. +//! Adding a field here is safe; a model gaining one should not change output. + +use serde::Serialize; +use soar_config::repository::Repository; +use soar_core::{ + database::models::{InstalledPackage, Package}, + package::install::InstallTarget, +}; +use soar_operations::{ApplyDiff, InstalledEntry, PackageListEntry, SearchEntry, UpdateInfo}; + +/// A package as published by a repository. +#[derive(Serialize)] +pub struct PackageJson { + pub name: String, + pub family: Option, + pub repo: String, + pub version: String, + pub description: String, + pub pkg_type: Option, + pub size: Option, + pub installed: bool, + /// Other versions the repository publishes, newest first. + pub other_versions: Vec, +} + +impl PackageJson { + fn new(package: &Package, installed: bool, other_versions: Vec) -> Self { + Self { + name: package.pkg_name.clone(), + family: package.pkg_family.clone(), + repo: package.repo_name.clone(), + version: package.version.clone(), + description: package.description.clone(), + pkg_type: package.pkg_type.clone(), + size: package.ghcr_size.or(package.size), + installed, + other_versions, + } + } +} + +impl From<&PackageListEntry> for PackageJson { + fn from(entry: &PackageListEntry) -> Self { + Self::new( + &entry.package, + entry.installed, + entry.other_versions.clone(), + ) + } +} + +impl From<&SearchEntry> for PackageJson { + fn from(entry: &SearchEntry) -> Self { + Self::new( + &entry.package, + entry.installed, + entry.other_versions.clone(), + ) + } +} + +/// A package installed on this system. +#[derive(Serialize)] +pub struct InstalledJson { + pub name: String, + pub family: Option, + pub repo: String, + pub version: String, + pub pkg_type: Option, + pub installed_path: String, + pub installed_date: String, + /// Size on disk, which is not the download size. + pub disk_size: u64, + pub pinned: bool, + /// False when the install did not finish. + pub healthy: bool, +} + +impl From<&InstalledEntry> for InstalledJson { + fn from(entry: &InstalledEntry) -> Self { + let package: &InstalledPackage = &entry.package; + Self { + name: package.pkg_name.clone(), + family: package.pkg_family.clone(), + repo: package.repo_name.clone(), + version: package.version.clone(), + pkg_type: package.pkg_type.clone(), + installed_path: package.installed_path.clone(), + installed_date: package.installed_date.clone(), + disk_size: entry.disk_size, + pinned: package.pinned, + healthy: entry.is_healthy, + } + } +} + +/// Everything known about one package, as `query` reports it. +#[derive(Serialize)] +pub struct PackageDetailJson { + pub name: String, + pub family: Option, + pub repo: String, + pub version: String, + pub description: String, + pub pkg_type: Option, + pub size: Option, + /// blake3, as a download is verified against. + pub checksum: Option, + pub homepages: Vec, + pub source_urls: Vec, + pub licenses: Vec, + pub categories: Vec, + pub notes: Vec, + pub download_url: String, + pub build_date: Option, + /// Formatted for display. + pub maintainers: Vec, +} + +impl From<&Package> for PackageDetailJson { + fn from(package: &Package) -> Self { + Self { + name: package.pkg_name.clone(), + family: package.pkg_family.clone(), + repo: package.repo_name.clone(), + version: package.version.clone(), + description: package.description.clone(), + pkg_type: package.pkg_type.clone(), + size: package.ghcr_size.or(package.size), + checksum: package.bsum.clone(), + homepages: package.homepages.clone().unwrap_or_default(), + source_urls: package.source_urls.clone().unwrap_or_default(), + licenses: package.licenses.clone().unwrap_or_default(), + categories: package.categories.clone().unwrap_or_default(), + notes: package.notes.clone().unwrap_or_default(), + download_url: package.download_url.clone(), + build_date: package.build_date.clone(), + maintainers: package + .maintainers + .as_ref() + .map(|all| all.iter().map(ToString::to_string).collect()) + .unwrap_or_default(), + } + } +} + +/// A package with a newer version waiting for it. +#[derive(Serialize)] +pub struct UpdateJson { + pub name: String, + pub family: Option, + pub repo: String, + pub current_version: String, + pub new_version: String, + pub size: Option, +} + +impl From<&UpdateInfo> for UpdateJson { + fn from(update: &UpdateInfo) -> Self { + let package = &update.target.package; + Self { + name: update.pkg_name.clone(), + family: package.pkg_family.clone(), + repo: update.repo_name.clone(), + current_version: update.current_version.clone(), + new_version: update.new_version.clone(), + size: package.ghcr_size.or(package.size), + } + } +} + +/// A repository soar is configured to read. +#[derive(Serialize)] +pub struct RepositoryJson { + pub name: String, + pub url: String, + pub enabled: bool, + pub signature_verification: bool, + pub desktop_integration: bool, +} + +impl From<&Repository> for RepositoryJson { + fn from(repo: &Repository) -> Self { + Self { + name: repo.name.clone(), + url: repo.url.clone(), + enabled: repo.is_enabled(), + signature_verification: repo.signature_verification.unwrap_or(false), + desktop_integration: repo.desktop_integration.unwrap_or(false), + } + } +} + +/// Where soar keeps its files, so a frontend can read and write the same ones. +#[derive(Serialize)] +pub struct EnvJson { + pub config: String, + pub packages_config: String, + pub bin: String, + pub db: String, + pub cache: String, + pub packages: String, + pub repositories: String, +} + +/// One package the declarative configuration would change. +#[derive(Serialize)] +pub struct ApplyChangeJson { + pub name: String, + pub family: Option, + pub repo: String, + pub version: String, + /// The version on disk now, for a package being replaced. + pub current_version: Option, +} + +/// What applying the declarative configuration would do. +#[derive(Serialize)] +pub struct ApplyDiffJson { + pub to_install: Vec, + pub to_update: Vec, + pub to_remove: Vec, + pub in_sync: Vec, + pub not_found: Vec, +} + +impl ApplyDiffJson { + pub fn new(diff: &ApplyDiff) -> Self { + let change = |target: &InstallTarget| { + let package = &target.package; + ApplyChangeJson { + name: package.pkg_name.clone(), + family: package.pkg_family.clone(), + repo: package.repo_name.clone(), + version: package.version.clone(), + current_version: target.existing_install.as_ref().map(|e| e.version.clone()), + } + }; + + Self { + to_install: diff.to_install.iter().map(|(_, t)| change(t)).collect(), + to_update: diff.to_update.iter().map(|(_, t)| change(t)).collect(), + to_remove: diff + .to_remove + .iter() + .map(|package| { + ApplyChangeJson { + name: package.pkg_name.clone(), + family: package.pkg_family.clone(), + repo: package.repo_name.clone(), + version: package.version.clone(), + current_version: Some(package.version.clone()), + } + }) + .collect(), + in_sync: diff.in_sync.clone(), + not_found: diff.not_found.clone(), + } + } +} + +/// Wraps a listing so fields can be added without changing the shape. +#[derive(Serialize)] +pub struct Listing { + pub items: Vec, + pub total: usize, +} + +impl Listing { + pub fn new(items: Vec, total: usize) -> Self { + Self { + items, + total, + } + } +} + +/// Write a result to stdout as a single JSON document. +pub fn emit(value: &T) { + if let Ok(json) = serde_json::to_string(value) { + println!("{json}"); + } +} diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index 688b1fe2..1345110e 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -10,8 +10,12 @@ use tabled::{ }; use tracing::{debug, info}; -use crate::utils::{ - display_settings, icon_or, pretty_package_size, term_width, vec_string, Colored, Icons, +use crate::{ + json_output::{self, InstalledJson, Listing, PackageDetailJson, PackageJson}, + utils::{ + display_settings, event_stream_enabled, icon_or, pretty_package_size, term_width, + vec_string, Colored, Icons, + }, }; pub async fn search_packages( @@ -29,6 +33,12 @@ pub async fn search_packages( let result = search::search_packages(ctx, &query, case_sensitive, limit).await?; + if event_stream_enabled() { + let items: Vec = result.packages.iter().map(Into::into).collect(); + json_output::emit(&Listing::new(items, result.total_count)); + return Ok(()); + } + let total = result.total_count; let display_count = result.packages.len(); @@ -130,6 +140,13 @@ pub async fn query_package(ctx: &SoarContext, query_str: String) -> SoarResult<( let packages = search::query_package(ctx, &query_str).await?; + if event_stream_enabled() { + let items: Vec = packages.iter().map(Into::into).collect(); + let total = items.len(); + json_output::emit(&Listing::new(items, total)); + return Ok(()); + } + for package in packages { let mut builder = Builder::new(); @@ -289,6 +306,12 @@ pub async fn list_packages(ctx: &SoarContext, repo_name: Option) -> Soar let result = list::list_packages(ctx, repo_name.as_deref()).await?; + if event_stream_enabled() { + let items: Vec = result.packages.iter().map(Into::into).collect(); + json_output::emit(&Listing::new(items, result.total)); + return Ok(()); + } + let total = result.total; let mut installed_count = 0; let mut available_count = 0; @@ -387,6 +410,12 @@ pub async fn list_installed_packages( let result = list::list_installed(ctx, repo_name.as_deref())?; + if event_stream_enabled() { + let items: Vec = result.packages.iter().map(Into::into).collect(); + json_output::emit(&Listing::new(items, result.total_count)); + return Ok(()); + } + let mut unique_pkgs = HashSet::new(); let settings = display_settings(); let use_icons = settings.icons(); diff --git a/crates/soar-cli/src/logging.rs b/crates/soar-cli/src/logging.rs index 2381d38e..567b3dd6 100644 --- a/crates/soar-cli/src/logging.rs +++ b/crates/soar-cli/src/logging.rs @@ -56,11 +56,19 @@ where } } -struct WriterBuilder; +/// Chooses which stream log records go to. +/// +/// Info goes to stdout and the rest to stderr, unless soar is emitting an +/// event stream, in which case stdout carries only that. +struct WriterBuilder { + logs_to_stderr: bool, +} impl WriterBuilder { - fn new() -> Self { - Self + fn new(logs_to_stderr: bool) -> Self { + Self { + logs_to_stderr, + } } } @@ -117,11 +125,11 @@ impl<'a> MakeWriter<'a> for WriterBuilder { type Writer = SuspendingWriter; fn make_writer(&'a self) -> Self::Writer { - SuspendingWriter::new(false) + SuspendingWriter::new(self.logs_to_stderr) } fn make_writer_for(&'a self, meta: &tracing::Metadata<'_>) -> Self::Writer { - SuspendingWriter::new(meta.level() != &tracing::Level::INFO) + SuspendingWriter::new(self.logs_to_stderr || meta.level() != &tracing::Level::INFO) } } @@ -144,7 +152,7 @@ pub fn setup_logging(args: &Args) { .with_file(false) .with_line_number(false) .with_span_events(FmtSpan::NONE) - .with_writer(WriterBuilder::new()) + .with_writer(WriterBuilder::new(args.json)) .compact() .without_time(); diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index 68633928..52e5265d 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -45,8 +45,10 @@ mod health; mod inspect; mod install; mod json2db; +mod json_output; mod list; mod logging; +mod plugin_manifest; mod progress; mod remove; mod repo; @@ -63,8 +65,24 @@ mod self_actions; use self_actions::process_self_action; pub fn create_context() -> (SoarContext, Option) { + create_context_for(false) +} + +/// Build the context, putting the event stream where the command leaves room +/// for it: a command answering with one JSON document keeps stdout for the +/// answer, so its events go to stderr. +pub fn create_context_for(answers_with_document: bool) -> (SoarContext, Option) { let config = get_config(); + if utils::event_stream_enabled() { + let events: EventSinkHandle = if answers_with_document { + Arc::new(soar_events::JsonLinesSink::stderr()) + } else { + Arc::new(soar_events::JsonLinesSink::stdout()) + }; + return (SoarContext::new(config, events), None); + } + if progress_enabled() { let (sink, receiver) = soar_events::ChannelSink::new(); let events: EventSinkHandle = Arc::new(sink); @@ -78,6 +96,29 @@ pub fn create_context() -> (SoarContext, Option) { } } +/// Whether `--json` makes this command answer with a single JSON document. +fn answers_with_document(command: &cli::Commands) -> bool { + matches!( + command, + cli::Commands::ListPackages { .. } + | cli::Commands::ListInstalledPackages { .. } + | cli::Commands::Search { .. } + | cli::Commands::Query { .. } + | cli::Commands::Env + | cli::Commands::Update { + check: true, + .. + } + | cli::Commands::Apply { + dry_run: true, + .. + } + | cli::Commands::Repo { + action: cli::RepoAction::List, + } + ) +} + /// Handle system mode - check for root privileges and re-exec with sudo/doas if needed fn handle_system_mode() -> SoarResult<()> { if nix::unistd::geteuid().is_root() { @@ -147,6 +188,12 @@ async fn handle_cli() -> SoarResult<()> { *progress = false; } + if args.json { + *utils::EVENT_STREAM.write().unwrap() = true; + // The progress display writes to the same stream as the events. + *utils::PROGRESS.write().unwrap() = false; + } + if args.system { handle_system_mode()?; } @@ -213,7 +260,7 @@ async fn handle_cli() -> SoarResult<()> { setup_required_paths().unwrap(); - let (ctx, progress_guard) = create_context(); + let (ctx, progress_guard) = create_context_for(answers_with_document(&command)); let mut run_exit_code = None; match command { @@ -290,9 +337,10 @@ async fn handle_cli() -> SoarResult<()> { packages, keep, ask, + check, no_verify, } => { - update_packages(&ctx, packages, keep, ask, no_verify).await?; + update_packages(&ctx, packages, keep, ask, check, no_verify).await?; } cli::Commands::ListInstalledPackages { repo_name, @@ -389,21 +437,36 @@ async fn handle_cli() -> SoarResult<()> { } => { repo::handle_repo_action(&ctx, action)?; } + cli::Commands::PluginManifest => { + let profiles: Vec = get_config().profile.keys().cloned().collect(); + print!("{}", plugin_manifest::manifest(&profiles)); + } cli::Commands::Env => { let config = get_config(); + let paths = json_output::EnvJson { + config: CONFIG_PATH.read()?.display().to_string(), + packages_config: soar_config::packages::PACKAGES_CONFIG_PATH + .read()? + .display() + .to_string(), + bin: config.get_bin_path()?.display().to_string(), + db: config.get_db_path()?.display().to_string(), + cache: config.get_cache_path()?.display().to_string(), + packages: config.get_packages_path(None)?.display().to_string(), + repositories: config.get_repositories_path()?.display().to_string(), + }; - info!("SOAR_CONFIG={}", CONFIG_PATH.read()?.display()); - info!("SOAR_BIN={}", config.get_bin_path()?.display()); - info!("SOAR_DB={}", config.get_db_path()?.display()); - info!("SOAR_CACHE={}", config.get_cache_path()?.display()); - info!( - "SOAR_PACKAGES={}", - config.get_packages_path(None)?.display() - ); - info!( - "SOAR_REPOSITORIES={}", - config.get_repositories_path()?.display() - ); + if utils::event_stream_enabled() { + json_output::emit(&paths); + } else { + info!("SOAR_CONFIG={}", paths.config); + info!("SOAR_PACKAGES_CONFIG={}", paths.packages_config); + info!("SOAR_BIN={}", paths.bin); + info!("SOAR_DB={}", paths.db); + info!("SOAR_CACHE={}", paths.cache); + info!("SOAR_PACKAGES={}", paths.packages); + info!("SOAR_REPOSITORIES={}", paths.repositories); + } } #[cfg(feature = "self")] cli::Commands::SelfCmd { @@ -529,5 +592,8 @@ async fn main() { if let Err(err) = handle_cli().await { // Use miette's error display for Diagnostic errors eprintln!("{:?}", miette::Report::new(err)); + // Anything driving soar reads the exit code to know whether the work + // happened, so a failure has to say so. + std::process::exit(1); } } diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs new file mode 100644 index 00000000..2746dff6 --- /dev/null +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -0,0 +1,284 @@ +//! The manifest describing how to drive this soar. +//! +//! A frontend that shells out to soar needs to know which arguments to pass and +//! how to read what comes back. Shipping that description in the binary keeps +//! the two from falling out of step. + +/// The manifest format. A reader should refuse a version it does not know. +const SCHEMA_VERSION: u32 = 1; + +/// The manifest, with `@schema_version@`, `@version@` and `@profiles@` filled +/// in by [`manifest`]. +const TEMPLATE: &str = r#"# Generated by `soar plugin-manifest`. This describes the soar that +# emitted it, so it is worth regenerating rather than copying between machines. +schema_version = @schema_version@ +id = "soar" +name = "Soar" +version = "@version@" + +# Most specific form first: a package published under a family is named by it, +# and one published without a family is named on its own. +selector = ["{family}/{name}", "{name}"] + +# Soar manages a separate set of packages for everyone, reached with the same +# command and one more flag. It elevates itself when it has to, which a window +# cannot answer, so it is run elevated instead. +[system] +args = ["--system"] +elevate = true + +[detect] +command = "soar" +version = ["--version"] +min_version = "@version@" + +# The settings, as they are named in the configuration file. A frontend reads +# and writes that file directly, so this says what is in it rather than +# standing in front of it. +[[config]] +key = "parallel" +label = "Parallel downloads" +type = "toggle" +default = true + +[[config]] +key = "parallel_limit" +label = "Parallel limit" +type = "number" +default = 4 + +[[config]] +key = "search_limit" +label = "Search result limit" +type = "number" +default = 20 + +[[config]] +key = "signature_verification" +label = "Signature verification" +type = "toggle" +default = true + +[[config]] +key = "desktop_integration" +label = "Desktop integration" +type = "toggle" +default = false + +[[config]] +key = "bin_path" +label = "Bin path" +type = "path_list" +section = "Paths" + +[[config]] +key = "cache_path" +label = "Cache path" +type = "path_list" +section = "Paths" + +[[config]] +key = "db_path" +label = "DB path" +type = "path_list" +section = "Paths" + +[[config]] +key = "desktop_path" +label = "Desktop path" +type = "path_list" +section = "Paths" + +[[config]] +key = "repositories_path" +label = "Repos path" +type = "path_list" +section = "Paths" + +[[config]] +key = "portable_dirs" +label = "Portable dirs" +type = "path_list" +section = "Paths" + +[[config]] +key = "sync_interval" +label = "Sync interval" +type = "text" +section = "Advanced" + +[[config]] +key = "default_profile" +label = "Default profile" +type = "select" +options = [@profiles@] +default = "default" +section = "Advanced" + +# Query commands answer with one JSON document; `items` is the array to read +# and `total` says how many matched before any limit was applied. +[ops.list] +args = ["--json", "list"] +output = { format = "json", select = "$.items[*]" } +fields = { name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", installed = "installed" } + +[ops.list_installed] +args = ["--json", "info"] +output = { format = "json", select = "$.items[*]" } +fields = { name = "name", family = "family", version = "version", repo = "repo", size = "disk_size", path = "installed_path", installed_at = "installed_date", pinned = "pinned", healthy = "healthy" } + +[ops.search] +args = ["--json", "search", "{query}"] +output = { format = "json", select = "$.items[*]" } +fields = { name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", installed = "installed" } + +[ops.info] +args = ["--json", "query", "{selector}"] +output = { format = "json", select = "$.items[*]" } +fields = { name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", checksum = "checksum", homepage = "homepages", license = "licenses", category = "categories", pkg_type = "pkg_type", source = "source_urls", build_date = "build_date", download_url = "download_url" } + +# Everything else soar knows about a package. A frontend shows these as +# given, so the labels are what a reader sees. +[[ops.info.extra]] +label = "Maintainer" +field = "maintainers" + +[[ops.info.extra]] +label = "Checksum" +field = "checksum" + +[ops.list_updates] +args = ["--json", "update", "--check"] +output = { format = "json", select = "$.items[*]" } +fields = { name = "name", family = "family", repo = "repo", version = "current_version", current_version = "current_version", new_version = "new_version", size = "size" } + +[ops.list_repos] +args = ["--json", "repo", "list"] +output = { format = "json", select = "$.items[*]" } +fields = { name = "name", url = "url", enabled = "enabled" } + +# Where soar keeps its files, so they can be read and written directly rather +# than through a command for every field. +[ops.paths] +args = ["--json", "env"] +output = { format = "json" } +fields = { config = "config", packages_config = "packages_config", bin = "bin", db = "db", cache = "cache", packages = "packages", repositories = "repositories" } + +# What applying the declarative configuration would change, asked before it is +# applied rather than reported while it happens. +# Always pruning, so the answer says which installed packages are undeclared. +# Whether they are actually removed is decided when applying. +[ops.apply_check] +args = ["--json", "apply", "--dry-run", "--prune"] +output = { format = "json" } + +# Operations answer with a stream instead: one JSON object per line, written as +# it happens, so progress can be shown while the work is still running. +[ops.install] +args = ["--json", "install", "--yes", "{selector}"] +output = { format = "ndjson" } +progress = { event = "type", stage = "stage", current = "current", total = "total", message = "pkg_name" } + +[ops.remove] +args = ["--json", "remove", "--yes", "{selector}"] +output = { format = "ndjson" } +progress = { event = "type", stage = "stage", message = "pkg_name" } + +[ops.update] +args = ["--json", "update"] +output = { format = "ndjson" } +progress = { event = "type", stage = "stage", current = "current", total = "total", message = "pkg_name" } + +[ops.sync] +args = ["--json", "sync"] +output = { format = "ndjson" } +progress = { event = "type", stage = "stage", message = "repo_name" } + +# Pruning is a different command rather than a flag on this one, because an +# operation is one argv and nothing here is conditional. +[ops.apply] +args = ["--json", "apply", "--yes"] +output = { format = "ndjson" } +progress = { event = "type", stage = "stage", current = "current", total = "total", message = "pkg_name" } + +[ops.apply_prune] +args = ["--json", "apply", "--yes", "--prune"] +output = { format = "ndjson" } +progress = { event = "type", stage = "stage", current = "current", total = "total", message = "pkg_name" } + +# Writing a first configuration file. Soar needs a whole one to start from, +# so a frontend meaning to edit settings asks for this before writing any. +[ops.default_config] +args = ["defconfig"] +output = { format = "ndjson" } + +# Repository commands report nothing on success, so there is no shape to read. +[ops.add_repo] +args = ["repo", "add", "{name}", "{url}"] +output = { format = "ndjson" } + +[ops.remove_repo] +args = ["repo", "remove", "{name}"] +output = { format = "ndjson" } + +[ops.set_repo_enabled] +args = ["repo", "update", "{name}", "--enabled", "{enabled}"] +output = { format = "ndjson" } +"#; + +/// Describes soar's commands as this build accepts them. +/// +/// `{selector}`, `{name}`, `{query}` and `{version}` are the only substitutions +/// a caller makes; `selector` says how to build the first from a package's +/// fields. `profiles` are the ones this machine has, which is why the manifest +/// is generated rather than shipped as a file. +pub fn manifest(profiles: &[String]) -> String { + let options = profiles + .iter() + .map(|profile| format!("{profile:?}")) + .collect::>() + .join(", "); + + TEMPLATE + .replace("@schema_version@", &SCHEMA_VERSION.to_string()) + .replace("@version@", env!("CARGO_PKG_VERSION")) + .replace("@profiles@", &options) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_manifest_is_valid_toml() { + let parsed: toml::Value = + toml::from_str(&manifest(&["default".into()])).expect("manifest must parse"); + assert_eq!(parsed["schema_version"].as_integer(), Some(1)); + assert_eq!(parsed["id"].as_str(), Some("soar")); + } + + #[test] + fn every_operation_names_a_real_subcommand() { + use clap::CommandFactory; + + let parsed: toml::Value = toml::from_str(&manifest(&["default".into()])).unwrap(); + let known: Vec = crate::cli::Args::command() + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + for (op, table) in parsed["ops"].as_table().expect("ops table") { + let args = table["args"].as_array().expect("args array"); + // The first argument that is not a global flag is the subcommand. + let subcommand = args + .iter() + .filter_map(|a| a.as_str()) + .find(|a| !a.starts_with('-')) + .unwrap_or_else(|| panic!("{op} names no subcommand")); + assert!( + known.iter().any(|k| k == subcommand), + "{op} runs `{subcommand}`, which this soar does not have" + ); + } + } +} diff --git a/crates/soar-cli/src/repo.rs b/crates/soar-cli/src/repo.rs index 84edac6d..b54d9ae0 100644 --- a/crates/soar-cli/src/repo.rs +++ b/crates/soar-cli/src/repo.rs @@ -3,7 +3,11 @@ use soar_core::SoarResult; use soar_operations::{repo::RepoUpdate, SoarContext}; use tracing::info; -use crate::cli::RepoAction; +use crate::{ + cli::RepoAction, + json_output::{self, Listing, RepositoryJson}, + utils::event_stream_enabled, +}; pub fn handle_repo_action(ctx: &SoarContext, action: RepoAction) -> SoarResult<()> { match action { @@ -57,6 +61,14 @@ pub fn handle_repo_action(ctx: &SoarContext, action: RepoAction) -> SoarResult<( } RepoAction::List => { let config = soar_config::config::get_config(); + + if event_stream_enabled() { + let items: Vec = + config.repositories.iter().map(Into::into).collect(); + json_output::emit(&Listing::new(items, config.repositories.len())); + return Ok(()); + } + if config.repositories.is_empty() { info!("No repositories configured."); } else { diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 6384e681..d3fd469c 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -1,37 +1,37 @@ use nu_ansi_term::Color::{Blue, Cyan, Green, Red}; use soar_core::SoarResult; -use soar_operations::{update, SoarContext, UpdateReport}; +use soar_operations::{update, SoarContext, UpdateInfo, UpdateReport}; use tabled::{ builder::Builder, settings::{themes::BorderCorrection, Panel, Style}, }; use tracing::{error, info}; -use crate::utils::{ask_target_action, display_settings, icon_or, Colored, Icons}; +use crate::{ + json_output::{self, Listing, UpdateJson}, + utils::{ask_target_action, display_settings, event_stream_enabled, icon_or, Colored, Icons}, +}; pub async fn update_packages( ctx: &SoarContext, packages: Option>, keep: bool, ask: bool, + check: bool, no_verify: bool, ) -> SoarResult<()> { let updates = update::check_updates(ctx, packages.as_deref()).await?; + if check { + return report_pending(&updates); + } + if updates.is_empty() { info!("No packages to update."); return Ok(()); } - // Display update info - for update_info in &updates { - info!( - "{}: {} -> {}", - Colored(Blue, &update_info.pkg_name), - Colored(Red, &update_info.current_version), - Colored(Green, &update_info.new_version), - ); - } + display_pending(&updates); if ask { let install_targets: Vec<_> = updates.iter().map(|u| u.target.clone()).collect(); @@ -44,6 +44,35 @@ pub async fn update_packages( Ok(()) } +/// Say what is waiting to be updated, and stop there. +fn report_pending(updates: &[UpdateInfo]) -> SoarResult<()> { + if event_stream_enabled() { + let items: Vec = updates.iter().map(Into::into).collect(); + json_output::emit(&Listing::new(items, updates.len())); + return Ok(()); + } + + if updates.is_empty() { + info!("No packages to update."); + return Ok(()); + } + + display_pending(updates); + + Ok(()) +} + +fn display_pending(updates: &[UpdateInfo]) { + for update_info in updates { + info!( + "{}: {} -> {}", + Colored(Blue, &update_info.pkg_name), + Colored(Red, &update_info.current_version), + Colored(Green, &update_info.new_version), + ); + } +} + fn display_update_report(report: &UpdateReport) { let settings = display_settings(); let use_icons = settings.icons(); diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index f50ea67b..e9039ee2 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -65,11 +65,17 @@ pub fn term_width() -> usize { pub static COLOR: LazyLock> = LazyLock::new(|| RwLock::new(true)); pub static PROGRESS: LazyLock> = LazyLock::new(|| RwLock::new(true)); +/// Whether stdout carries the machine-readable event stream. +pub static EVENT_STREAM: LazyLock> = LazyLock::new(|| RwLock::new(false)); pub fn progress_enabled() -> bool { *PROGRESS.read().unwrap() } +pub fn event_stream_enabled() -> bool { + *EVENT_STREAM.read().unwrap() +} + pub fn interactive_ask(ques: &str) -> SoarResult { print!("{ques}"); diff --git a/crates/soar-db/src/connection.rs b/crates/soar-db/src/connection.rs index 8e1d0a7d..cc638c8c 100644 --- a/crates/soar-db/src/connection.rs +++ b/crates/soar-db/src/connection.rs @@ -13,11 +13,33 @@ use tracing::{debug, trace}; use crate::migration::{apply_migrations, migrate_json_to_jsonb, DbType}; +/// How long to wait for another process to let go of the database. +/// +/// Without it SQLite gives up the moment it finds a lock, so two soar +/// processes running at once fail rather than queue. +const BUSY_TIMEOUT_MS: u32 = 5_000; + /// Database connection wrapper with migration support. pub struct DbConnection { conn: SqliteConnection, } +/// Set the pragmas every connection wants, before anything reads or writes. +fn prepare(conn: &mut SqliteConnection) -> Result<(), ConnectionError> { + sql_query(format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS};")) + .execute(conn) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + + // WAL mode for better concurrent access + sql_query("PRAGMA journal_mode = WAL;") + .execute(conn) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + + trace!("busy timeout and WAL journal mode set"); + + Ok(()) +} + impl DbConnection { /// Opens a database connection and runs migrations. /// @@ -36,10 +58,7 @@ impl DbConnection { let mut conn = SqliteConnection::establish(&path_str)?; trace!("database connection established"); - sql_query("PRAGMA journal_mode = WAL;") - .execute(&mut conn) - .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; - trace!("WAL journal mode enabled"); + prepare(&mut conn)?; apply_migrations(&mut conn, &db_type) .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; @@ -92,11 +111,7 @@ impl DbConnection { let mut conn = SqliteConnection::establish(&path_str)?; trace!("database connection established"); - // WAL mode for better concurrent access - sql_query("PRAGMA journal_mode = WAL;") - .execute(&mut conn) - .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; - trace!("WAL journal mode enabled"); + prepare(&mut conn)?; debug!(path = %path_str, "database opened successfully"); Ok(Self { @@ -117,11 +132,7 @@ impl DbConnection { let mut conn = SqliteConnection::establish(&path_str)?; trace!("metadata database connection established"); - // WAL mode for better concurrent access - sql_query("PRAGMA journal_mode = WAL;") - .execute(&mut conn) - .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; - trace!("WAL journal mode enabled"); + prepare(&mut conn)?; // Migrate text JSON to JSONB binary format migrate_json_to_jsonb(&mut conn, DbType::Metadata) diff --git a/crates/soar-events/Cargo.toml b/crates/soar-events/Cargo.toml index 68219db7..0796ae3b 100644 --- a/crates/soar-events/Cargo.toml +++ b/crates/soar-events/Cargo.toml @@ -8,3 +8,7 @@ repository.workspace = true keywords.workspace = true readme.workspace = true categories.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/crates/soar-events/src/event.rs b/crates/soar-events/src/event.rs index 6216daed..3f0162c7 100644 --- a/crates/soar-events/src/event.rs +++ b/crates/soar-events/src/event.rs @@ -1,7 +1,8 @@ use crate::OperationId; /// All event types emitted by soar operations. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum SoarEvent { /// Download is starting. DownloadStarting { @@ -112,12 +113,20 @@ pub enum SoarEvent { total: u32, failed: u32, }, + /// What applying the declarative configuration ended up doing. + ApplyComplete { + installed: usize, + updated: usize, + removed: usize, + failed: usize, + }, /// Log message. Log { level: LogLevel, message: String }, } /// Verification stages. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum VerifyStage { /// Calculating and verifying checksum (blake3). Checksum, @@ -130,7 +139,8 @@ pub enum VerifyStage { } /// Installation stages. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum InstallStage { /// Extracting package archive. Extracting, @@ -151,7 +161,8 @@ pub enum InstallStage { } /// Package removal stages. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum RemoveStage { /// Running pre-remove hook. RunningHook(String), @@ -170,7 +181,8 @@ pub enum RemoveStage { } /// Repository sync stages. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum SyncStage { /// Fetching metadata from remote. Fetching, @@ -187,7 +199,8 @@ pub enum SyncStage { } /// Update check result for a single package. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum UpdateCheckStatus { /// A newer version is available. Available { @@ -201,7 +214,8 @@ pub enum UpdateCheckStatus { } /// Old version cleanup stages after update. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum UpdateCleanupStage { /// Removing the old version. Removing, @@ -212,7 +226,8 @@ pub enum UpdateCleanupStage { } /// Hook execution stages. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum HookStage { /// Hook is starting. Starting, @@ -223,7 +238,8 @@ pub enum HookStage { } /// Package execution stages (run command). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum RunStage { /// Using a cached binary (already downloaded). CacheHit, @@ -236,7 +252,8 @@ pub enum RunStage { } /// Build stages (for source packages). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum BuildStage { /// Running build command N of M. Running { @@ -250,7 +267,8 @@ pub enum BuildStage { } /// Log levels. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum LogLevel { Debug, Info, diff --git a/crates/soar-events/src/sink.rs b/crates/soar-events/src/sink.rs index 5301e489..8ba08930 100644 --- a/crates/soar-events/src/sink.rs +++ b/crates/soar-events/src/sink.rs @@ -1,4 +1,10 @@ -use std::sync::mpsc::{self, Receiver, Sender}; +use std::{ + io::{self, Write}, + sync::{ + mpsc::{self, Receiver, Sender}, + Mutex, + }, +}; use crate::SoarEvent; @@ -47,7 +53,7 @@ impl EventSink for NullSink { /// Useful in tests to verify that expected events were emitted. #[derive(Default)] pub struct CollectorSink { - events: std::sync::Mutex>, + events: Mutex>, } impl CollectorSink { @@ -69,3 +75,48 @@ impl EventSink for CollectorSink { self.events.lock().unwrap().push(event); } } + +/// Writes each event as one JSON object per line. +/// +/// A line is a complete event, so a reader needs no closing bracket and a +/// stream cut short still parses up to the last full line. +pub struct JsonLinesSink { + writer: Mutex, +} + +impl JsonLinesSink { + pub fn new(writer: W) -> Self { + Self { + writer: Mutex::new(writer), + } + } +} + +impl JsonLinesSink { + /// Writes to stdout, where a frontend expects the stream. + pub fn stdout() -> Self { + Self::new(io::stdout()) + } +} + +impl JsonLinesSink { + /// Writes beside the answer, for a command whose stdout carries one JSON + /// document. + pub fn stderr() -> Self { + Self::new(io::stderr()) + } +} + +impl EventSink for JsonLinesSink { + fn emit(&self, event: SoarEvent) { + let Ok(line) = serde_json::to_string(&event) else { + return; + }; + let Ok(mut writer) = self.writer.lock() else { + return; + }; + // Flushed per event: a frontend needs it now, not when the buffer fills. + let _ = writeln!(writer, "{line}"); + let _ = writer.flush(); + } +} diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index 2823a018..d722a834 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -361,6 +361,13 @@ pub async fn execute_apply( } } + ctx.events().emit(SoarEvent::ApplyComplete { + installed: installed_count, + updated: updated_count, + removed: removed_count, + failed: failed_count, + }); + Ok(ApplyReport { installed_count, updated_count, diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index b2633424..96dd872e 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -348,6 +348,29 @@ pub async fn query_package(ctx: &SoarContext, query_str: &str) -> SoarResult = maintainers + .into_iter() + .map(|m| { + soar_core::database::models::Maintainer { + name: m.name, + contact: m.contact, + } + }) + .collect(); + + if !named.is_empty() { + package.maintainers = Some(named); + } + } + } + // The query is ordered by name, which says nothing about several versions // of one package. Newest first, so the one that would be installed is on // top. diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 76ea1364..615614d9 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -117,7 +117,6 @@ where /// /// # Required Fields /// -/// - `pkg_id` - Unique package identifier /// - `pkg_name` - Human-readable package name /// - `description` - Package description /// - `version` - Package version string