From 798956013168283b3d29b4505bb34828884a00fe Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 09:08:15 +0545 Subject: [PATCH 01/19] feat(events): emit the event stream as json lines --- Cargo.lock | 4 ++++ crates/soar-cli/src/logging.rs | 21 +++++++++++++----- crates/soar-cli/src/main.rs | 12 ++++++++++ crates/soar-cli/src/utils.rs | 6 +++++ crates/soar-events/Cargo.toml | 4 ++++ crates/soar-events/src/event.rs | 33 ++++++++++++++++++---------- crates/soar-events/src/sink.rs | 39 +++++++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index faa9a50a9..e187c2a55 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/logging.rs b/crates/soar-cli/src/logging.rs index 2381d38ec..21a8d7299 100644 --- a/crates/soar-cli/src/logging.rs +++ b/crates/soar-cli/src/logging.rs @@ -56,11 +56,20 @@ where } } -struct WriterBuilder; +/// Chooses which stream log records go to. +/// +/// Normally informational output belongs on stdout with everything else on +/// stderr. When soar is emitting a machine-readable event stream, stdout +/// carries only that stream, so logs move aside entirely. +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 +126,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 +153,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 68633928c..cdd50ad52 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -65,6 +65,11 @@ use self_actions::process_self_action; pub fn create_context() -> (SoarContext, Option) { let config = get_config(); + if utils::event_stream_enabled() { + let events: EventSinkHandle = 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); @@ -147,6 +152,13 @@ async fn handle_cli() -> SoarResult<()> { *progress = false; } + if args.json { + *utils::EVENT_STREAM.write().unwrap() = true; + // The rendered progress display writes to the same stream the events + // go to, so only one of them can have it. + *utils::PROGRESS.write().unwrap() = false; + } + if args.system { handle_system_mode()?; } diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index f50ea67b2..e9039ee2b 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-events/Cargo.toml b/crates/soar-events/Cargo.toml index 68219db70..0796ae3b9 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 6216daed5..ccb1cc3a8 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 { @@ -117,7 +118,8 @@ pub enum SoarEvent { } /// 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 +132,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 +154,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 +174,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 +192,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 +207,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 +219,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 +231,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 +245,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 +260,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 5301e4893..7a32ca5f9 100644 --- a/crates/soar-events/src/sink.rs +++ b/crates/soar-events/src/sink.rs @@ -69,3 +69,42 @@ impl EventSink for CollectorSink { self.events.lock().unwrap().push(event); } } + +/// Writes each event as one JSON object per line. +/// +/// This is the shape a frontend driving soar over a pipe reads: a line is a +/// complete event, so a reader never has to buffer for a closing bracket, and +/// a stream cut short mid-operation still parses up to the last full line. +pub struct JsonLinesSink { + writer: std::sync::Mutex, +} + +impl JsonLinesSink { + pub fn new(writer: W) -> Self { + Self { + writer: std::sync::Mutex::new(writer), + } + } +} + +impl JsonLinesSink { + /// A sink writing to stdout, which is where a frontend expects the stream. + pub fn stdout() -> Self { + Self::new(std::io::stdout()) + } +} + +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 rendering progress needs it now, not + // when the buffer happens to fill. + let _ = writeln!(writer, "{line}"); + let _ = writer.flush(); + } +} From 9f59b04eccb79e795101ccdb8305849e9f073f41 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 09:14:49 +0545 Subject: [PATCH 02/19] feat(cli): report list, search, query and info as json --- crates/soar-cli/src/json_output.rs | 173 +++++++++++++++++++++++++++++ crates/soar-cli/src/list.rs | 33 +++++- crates/soar-cli/src/main.rs | 1 + 3 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 crates/soar-cli/src/json_output.rs diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs new file mode 100644 index 000000000..d91018b5c --- /dev/null +++ b/crates/soar-cli/src/json_output.rs @@ -0,0 +1,173 @@ +//! The shapes `--json` reports for the query commands. +//! +//! These are written out rather than derived from the internal models on +//! purpose. Anything a caller can read is a contract soar has to keep, and the +//! models carry fields that exist for the installer's benefit and would be +//! awkward to promise. Adding a field here is safe; a model gaining one is not +//! meant to change the output. + +use serde::Serialize; +use soar_core::database::models::{InstalledPackage, Package}; +use soar_operations::{InstalledEntry, PackageListEntry, SearchEntry}; + +/// A package as published by a repository. +#[derive(Serialize)] +pub struct PackageJson { + pub name: String, + pub family: Option, + pub pkg_id: 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. Only the newest + /// is reported above, so these say what is not being shown. + 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(), + pkg_id: package.pkg_id.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 pkg_id: Option, + pub repo: String, + pub version: String, + pub pkg_type: Option, + pub installed_path: String, + pub installed_date: String, + /// What the package occupies on disk, which is not the download size. + pub disk_size: u64, + pub pinned: bool, + /// False when the install did not finish, so the package is on disk but + /// not usable. + 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(), + pkg_id: package.pkg_id.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 pkg_id: Option, + pub repo: String, + pub version: String, + pub description: String, + pub pkg_type: Option, + pub size: Option, + /// blake3, which is what 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, +} + +impl From<&Package> for PackageDetailJson { + fn from(package: &Package) -> Self { + Self { + name: package.pkg_name.clone(), + family: package.pkg_family.clone(), + pkg_id: package.pkg_id.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(), + } + } +} + +/// What a command returns, wrapped so fields can be added later without +/// changing the shape a caller already reads. +#[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. +/// +/// Query commands answer once, so unlike the event stream this is one object +/// rather than a line per record. +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 688b1fe2a..1345110eb 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/main.rs b/crates/soar-cli/src/main.rs index cdd50ad52..2da7e877d 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -45,6 +45,7 @@ mod health; mod inspect; mod install; mod json2db; +mod json_output; mod list; mod logging; mod progress; From 881b6336dc932fec62cf093b05ab07fe7da81f4f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 09:18:54 +0545 Subject: [PATCH 03/19] feat(cli): describe how to drive soar with plugin-manifest --- crates/soar-cli/src/cli.rs | 4 + crates/soar-cli/src/main.rs | 4 + crates/soar-cli/src/plugin_manifest.rs | 118 +++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 crates/soar-cli/src/plugin_manifest.rs diff --git a/crates/soar-cli/src/cli.rs b/crates/soar-cli/src/cli.rs index b3f69bcb3..614282c76 100644 --- a/crates/soar-cli/src/cli.rs +++ b/crates/soar-cli/src/cli.rs @@ -487,6 +487,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/main.rs b/crates/soar-cli/src/main.rs index 2da7e877d..127cdd9d3 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -48,6 +48,7 @@ mod json2db; mod json_output; mod list; mod logging; +mod plugin_manifest; mod progress; mod remove; mod repo; @@ -402,6 +403,9 @@ async fn handle_cli() -> SoarResult<()> { } => { repo::handle_repo_action(&ctx, action)?; } + cli::Commands::PluginManifest => { + print!("{}", plugin_manifest::manifest()); + } cli::Commands::Env => { let config = get_config(); diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs new file mode 100644 index 000000000..c827bd32a --- /dev/null +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -0,0 +1,118 @@ +//! The adapter 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 inside the +//! binary means it always matches the binary that emitted it, which is the +//! whole point: a frontend linking soar as a library can fall out of step with +//! the soar the user actually runs, and this cannot. +//! +//! The shape is the one any package manager is described in, so a frontend has +//! a single code path and soar is not a special case. + +/// The manifest format this describes itself in. +/// +/// A reader should accept this and the version before it, and refuse anything +/// newer rather than guessing at fields it does not know. +const SCHEMA_VERSION: u32 = 1; + +/// Describes the commands as this build actually accepts them. +/// +/// `{name}`, `{query}` and `{version}` are the only substitutions. +pub fn manifest() -> String { + let version = env!("CARGO_PKG_VERSION"); + format!( + 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}" + +[detect] +command = "soar" +version = ["--version"] +min_version = "{version}" + +# 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", "{{name}}"] +output = {{ format = "json", select = "$.items[*]" }} +fields = {{ name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", checksum = "checksum", homepages = "homepages", licenses = "licenses", download_url = "download_url" }} + +# 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", "{{name}}"] +output = {{ format = "ndjson" }} +progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} + +[ops.remove] +args = ["--json", "remove", "--yes", "{{name}}"] +output = {{ format = "ndjson" }} +progress = {{ event = "type", message = "pkg_name" }} + +[ops.update] +args = ["--json", "update"] +output = {{ format = "ndjson" }} +progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} + +[ops.sync] +args = ["--json", "sync"] +output = {{ format = "ndjson" }} +progress = {{ event = "type", message = "repo_name" }} +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_manifest_is_valid_toml() { + let parsed: toml::Value = toml::from_str(&manifest()).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()).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" + ); + } + } +} From e8d0395a776b593afbf7964938b58117afdecdc4 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 09:48:40 +0545 Subject: [PATCH 04/19] fix(cli): keep a json answer alone on stdout --- crates/soar-cli/src/main.rs | 28 ++++++++++++++++++++++++++-- crates/soar-events/src/sink.rs | 11 +++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index 127cdd9d3..c1742828b 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -65,10 +65,23 @@ 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 that answers with one JSON document keeps stdout for the answer, +/// so anything it does on the way there is reported beside it instead. +pub fn create_context_for(answers_with_document: bool) -> (SoarContext, Option) { let config = get_config(); if utils::event_stream_enabled() { - let events: EventSinkHandle = Arc::new(soar_events::JsonLinesSink::stdout()); + 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); } @@ -85,6 +98,17 @@ 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 { .. } + ) +} + /// 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() { @@ -227,7 +251,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 { diff --git a/crates/soar-events/src/sink.rs b/crates/soar-events/src/sink.rs index 7a32ca5f9..82afefb44 100644 --- a/crates/soar-events/src/sink.rs +++ b/crates/soar-events/src/sink.rs @@ -94,6 +94,17 @@ impl JsonLinesSink { } } +impl JsonLinesSink { + /// A sink writing beside the answer rather than into it. + /// + /// A command answering with one JSON document cannot carry a stream on the + /// same output, since a reader expecting a document would find a second + /// thing after it. + pub fn stderr() -> Self { + Self::new(std::io::stderr()) + } +} + impl EventSink for JsonLinesSink { fn emit(&self, event: SoarEvent) { let Ok(line) = serde_json::to_string(&event) else { From ff79c0077bba8a524d0cbdbe84b36e75d9869ed5 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 09:48:40 +0545 Subject: [PATCH 05/19] feat(cli): name a package by its family in the manifest --- crates/soar-cli/src/plugin_manifest.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index c827bd32a..15623d901 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -17,7 +17,10 @@ const SCHEMA_VERSION: u32 = 1; /// Describes the commands as this build actually accepts them. /// -/// `{name}`, `{query}` and `{version}` are the only substitutions. +/// `{selector}`, `{name}`, `{query}` and `{version}` are the only +/// substitutions. `selector` says how to build the first of those out of a +/// package's fields, so two projects publishing the same command name stay +/// told apart. pub fn manifest() -> String { let version = env!("CARGO_PKG_VERSION"); format!( @@ -28,6 +31,10 @@ 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}}"] + [detect] command = "soar" version = ["--version"] @@ -51,19 +58,19 @@ 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", "{{name}}"] +args = ["--json", "query", "{{selector}}"] output = {{ format = "json", select = "$.items[*]" }} fields = {{ name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", checksum = "checksum", homepages = "homepages", licenses = "licenses", download_url = "download_url" }} # 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", "{{name}}"] +args = ["--json", "install", "--yes", "{{selector}}"] output = {{ format = "ndjson" }} progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} [ops.remove] -args = ["--json", "remove", "--yes", "{{name}}"] +args = ["--json", "remove", "--yes", "{{selector}}"] output = {{ format = "ndjson" }} progress = {{ event = "type", message = "pkg_name" }} From 6b119f3dd6734fbb3d28ad0aab28d93281870372 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 10:18:33 +0545 Subject: [PATCH 06/19] feat(cli): answer updates, repos, paths and diffs as json --- crates/soar-cli/src/apply.rs | 14 ++- crates/soar-cli/src/cli.rs | 4 + crates/soar-cli/src/json_output.rs | 126 ++++++++++++++++++++++++- crates/soar-cli/src/main.rs | 50 +++++++--- crates/soar-cli/src/plugin_manifest.rs | 41 ++++++++ crates/soar-cli/src/repo.rs | 17 +++- crates/soar-cli/src/update.rs | 51 +++++++--- 7 files changed, 274 insertions(+), 29 deletions(-) diff --git a/crates/soar-cli/src/apply.rs b/crates/soar-cli/src/apply.rs index 2528a649d..09c2bf473 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 614282c76..6525412cc 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, diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs index d91018b5c..a7767d410 100644 --- a/crates/soar-cli/src/json_output.rs +++ b/crates/soar-cli/src/json_output.rs @@ -7,8 +7,12 @@ //! meant to change the output. use serde::Serialize; -use soar_core::database::models::{InstalledPackage, Package}; -use soar_operations::{InstalledEntry, PackageListEntry, SearchEntry}; +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)] @@ -145,6 +149,124 @@ impl From<&Package> for PackageDetailJson { } } +/// A package with a newer version waiting for it. +#[derive(Serialize)] +pub struct UpdateJson { + pub name: String, + pub family: Option, + pub pkg_id: 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(), + pkg_id: package.pkg_id.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 everything, so a frontend can read and write the same +/// files rather than guessing at their locations. +#[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(), + } + } +} + /// What a command returns, wrapped so fields can be added later without /// changing the shape a caller already reads. #[derive(Serialize)] diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index c1742828b..8ee8eba28 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -106,6 +106,18 @@ fn answers_with_document(command: &cli::Commands) -> bool { | 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, + } ) } @@ -328,9 +340,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, @@ -432,19 +445,30 @@ async fn handle_cli() -> SoarResult<()> { } 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 { diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 15623d901..28999c9eb 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -62,6 +62,29 @@ args = ["--json", "query", "{{selector}}"] output = {{ format = "json", select = "$.items[*]" }} fields = {{ name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", checksum = "checksum", homepages = "homepages", licenses = "licenses", download_url = "download_url" }} +[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. +[ops.apply_check] +args = ["--json", "apply", "--dry-run"] +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] @@ -83,6 +106,24 @@ progress = {{ event = "type", current = "current", total = "total", message = "p args = ["--json", "sync"] output = {{ format = "ndjson" }} progress = {{ event = "type", message = "repo_name" }} + +[ops.apply] +args = ["--json", "apply", "--yes"] +output = {{ format = "ndjson" }} +progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} + +# 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" }} "# ) } diff --git a/crates/soar-cli/src/repo.rs b/crates/soar-cli/src/repo.rs index 84edac6d2..6e9b89f74 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,17 @@ 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(RepositoryJson::from) + .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 6384e681a..d9918bc83 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(UpdateJson::from).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(); From 8db4ef2d93e737e9fdbce36ac260d69b88d6ac7a Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 10:21:43 +0545 Subject: [PATCH 07/19] feat(events): say what applying the configuration did --- crates/soar-cli/src/plugin_manifest.rs | 11 ++++++++++- crates/soar-events/src/event.rs | 7 +++++++ crates/soar-operations/src/apply.rs | 7 +++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 28999c9eb..08aee47a9 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -81,8 +81,10 @@ fields = {{ config = "config", packages_config = "packages_config", bin = "bin", # 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"] +args = ["--json", "apply", "--dry-run", "--prune"] output = {{ format = "json" }} # Operations answer with a stream instead: one JSON object per line, written as @@ -107,11 +109,18 @@ args = ["--json", "sync"] output = {{ format = "ndjson" }} progress = {{ event = "type", 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", current = "current", total = "total", message = "pkg_name" }} +[ops.apply_prune] +args = ["--json", "apply", "--yes", "--prune"] +output = {{ format = "ndjson" }} +progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} + # Repository commands report nothing on success, so there is no shape to read. [ops.add_repo] args = ["repo", "add", "{{name}}", "{{url}}"] diff --git a/crates/soar-events/src/event.rs b/crates/soar-events/src/event.rs index ccb1cc3a8..3f0162c74 100644 --- a/crates/soar-events/src/event.rs +++ b/crates/soar-events/src/event.rs @@ -113,6 +113,13 @@ 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 }, } diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index 2823a018b..d722a834b 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, From a5d0bb26b9ecc1d313348312121f93e5a01900a3 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 11:37:22 +0545 Subject: [PATCH 08/19] feat(cli): describe soar's settings in the manifest --- crates/soar-cli/src/main.rs | 3 +- crates/soar-cli/src/plugin_manifest.rs | 114 ++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index 8ee8eba28..af3dceefd 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -441,7 +441,8 @@ async fn handle_cli() -> SoarResult<()> { repo::handle_repo_action(&ctx, action)?; } cli::Commands::PluginManifest => { - print!("{}", plugin_manifest::manifest()); + let profiles: Vec = get_config().profile.keys().cloned().collect(); + print!("{}", plugin_manifest::manifest(&profiles)); } cli::Commands::Env => { let config = get_config(); diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 08aee47a9..61d40dd7c 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -21,8 +21,19 @@ const SCHEMA_VERSION: u32 = 1; /// substitutions. `selector` says how to build the first of those out of a /// package's fields, so two projects publishing the same command name stay /// told apart. -pub fn manifest() -> String { +/// +/// The settings are described here too, so a frontend offering them does not +/// have to carry its own idea of what soar can be configured with. `profiles` +/// are the ones this machine has, which is why the description is generated +/// rather than written down somewhere. +pub fn manifest(profiles: &[String]) -> String { let version = env!("CARGO_PKG_VERSION"); + let profile_options = profiles + .iter() + .map(|profile| format!("{profile:?}")) + .collect::>() + .join(", "); + format!( r#"# Generated by `soar plugin-manifest`. This describes the soar that # emitted it, so it is worth regenerating rather than copying between machines. @@ -40,6 +51,96 @@ 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 = "ghcr_concurrency" +label = "GHCR concurrency" +type = "number" +default = 8 +section = "Advanced" + +[[config]] +key = "sync_interval" +label = "Sync interval" +type = "text" +section = "Advanced" + +[[config]] +key = "default_profile" +label = "Default profile" +type = "select" +options = [{profile_options}] +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] @@ -121,6 +222,12 @@ args = ["--json", "apply", "--yes", "--prune"] output = {{ format = "ndjson" }} progress = {{ event = "type", 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}}"] @@ -143,7 +250,8 @@ mod tests { #[test] fn the_manifest_is_valid_toml() { - let parsed: toml::Value = toml::from_str(&manifest()).expect("manifest must parse"); + 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")); } @@ -152,7 +260,7 @@ mod tests { fn every_operation_names_a_real_subcommand() { use clap::CommandFactory; - let parsed: toml::Value = toml::from_str(&manifest()).unwrap(); + 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()) From 8b6c2f9ddcde5fa8312d57dcfc341592cbe77736 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 17:26:51 +0545 Subject: [PATCH 09/19] fix(db): wait for a busy database instead of failing --- crates/soar-db/src/connection.rs | 39 ++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/crates/soar-db/src/connection.rs b/crates/soar-db/src/connection.rs index 8e1d0a7d4..8e3a31a6a 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. +/// +/// SQLite gives up the moment it finds a lock unless it is told otherwise, so +/// without this 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) From 35d916fd62078ebebbc9f63b99328d916972ee44 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 17:26:51 +0545 Subject: [PATCH 10/19] fix(cli): exit non-zero when a command fails --- crates/soar-cli/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index af3dceefd..a6213b897 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -595,5 +595,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); } } From 673315397162c705239bc872d28e8ca21230f019 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 17:26:51 +0545 Subject: [PATCH 11/19] feat(cli): report the stage each operation reaches --- crates/soar-cli/src/plugin_manifest.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 61d40dd7c..4136a5aff 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -193,34 +193,34 @@ output = {{ format = "json" }} [ops.install] args = ["--json", "install", "--yes", "{{selector}}"] output = {{ format = "ndjson" }} -progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} +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", message = "pkg_name" }} +progress = {{ event = "type", stage = "stage", message = "pkg_name" }} [ops.update] args = ["--json", "update"] output = {{ format = "ndjson" }} -progress = {{ event = "type", current = "current", total = "total", message = "pkg_name" }} +progress = {{ event = "type", stage = "stage", current = "current", total = "total", message = "pkg_name" }} [ops.sync] args = ["--json", "sync"] output = {{ format = "ndjson" }} -progress = {{ event = "type", message = "repo_name" }} +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", current = "current", total = "total", message = "pkg_name" }} +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", current = "current", total = "total", message = "pkg_name" }} +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. From 46d9a5eb45cbbe5ad39d224c6ffe318e9993cf5e Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 20:07:44 +0545 Subject: [PATCH 12/19] feat(cli): report the build date and richer package fields --- crates/soar-cli/src/json_output.rs | 2 ++ crates/soar-cli/src/plugin_manifest.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs index a7767d410..0fd85c97a 100644 --- a/crates/soar-cli/src/json_output.rs +++ b/crates/soar-cli/src/json_output.rs @@ -125,6 +125,7 @@ pub struct PackageDetailJson { pub categories: Vec, pub notes: Vec, pub download_url: String, + pub build_date: Option, } impl From<&Package> for PackageDetailJson { @@ -145,6 +146,7 @@ impl From<&Package> for PackageDetailJson { 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(), } } } diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 4136a5aff..966616cec 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -161,7 +161,7 @@ fields = {{ name = "name", family = "family", version = "version", repo = "repo" [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", homepages = "homepages", licenses = "licenses", download_url = "download_url" }} +fields = {{ name = "name", family = "family", version = "version", repo = "repo", description = "description", size = "size", checksum = "checksum", homepage = "homepages", license = "licenses", category = "categories", notes = "notes", build_date = "build_date", download_url = "download_url" }} [ops.list_updates] args = ["--json", "update", "--check"] From d4b9502ae77cbedbcc4a77432458372f3e1ec426 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 20:28:58 +0545 Subject: [PATCH 13/19] feat(cli): report the package type in query output --- crates/soar-cli/src/plugin_manifest.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 966616cec..3cb17ad7b 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -161,7 +161,7 @@ fields = {{ name = "name", family = "family", version = "version", repo = "repo" [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", notes = "notes", build_date = "build_date", download_url = "download_url" }} +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", build_date = "build_date", download_url = "download_url" }} [ops.list_updates] args = ["--json", "update", "--check"] From 1a4e1ae9ce4898ac02c1a75c822bffa8df431eec Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 3 Aug 2026 20:36:14 +0545 Subject: [PATCH 14/19] feat(cli): map the source url in the manifest --- crates/soar-cli/src/plugin_manifest.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 3cb17ad7b..3335a3f49 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -161,7 +161,7 @@ fields = {{ name = "name", family = "family", version = "version", repo = "repo" [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", build_date = "build_date", download_url = "download_url" }} +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" }} [ops.list_updates] args = ["--json", "update", "--check"] From e82c98392790f20f8461a70202eef3f6fff7fa73 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sun, 9 Aug 2026 23:09:06 +0545 Subject: [PATCH 15/19] refactor(cli): drop the unused pkg_id from json output --- crates/soar-cli/src/json_output.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs index 0fd85c97a..b86f50a3e 100644 --- a/crates/soar-cli/src/json_output.rs +++ b/crates/soar-cli/src/json_output.rs @@ -19,7 +19,6 @@ use soar_operations::{ApplyDiff, InstalledEntry, PackageListEntry, SearchEntry, pub struct PackageJson { pub name: String, pub family: Option, - pub pkg_id: Option, pub repo: String, pub version: String, pub description: String, @@ -36,7 +35,6 @@ impl PackageJson { Self { name: package.pkg_name.clone(), family: package.pkg_family.clone(), - pkg_id: package.pkg_id.clone(), repo: package.repo_name.clone(), version: package.version.clone(), description: package.description.clone(), @@ -73,7 +71,6 @@ impl From<&SearchEntry> for PackageJson { pub struct InstalledJson { pub name: String, pub family: Option, - pub pkg_id: Option, pub repo: String, pub version: String, pub pkg_type: Option, @@ -93,7 +90,6 @@ impl From<&InstalledEntry> for InstalledJson { Self { name: package.pkg_name.clone(), family: package.pkg_family.clone(), - pkg_id: package.pkg_id.clone(), repo: package.repo_name.clone(), version: package.version.clone(), pkg_type: package.pkg_type.clone(), @@ -111,7 +107,6 @@ impl From<&InstalledEntry> for InstalledJson { pub struct PackageDetailJson { pub name: String, pub family: Option, - pub pkg_id: Option, pub repo: String, pub version: String, pub description: String, @@ -133,7 +128,6 @@ impl From<&Package> for PackageDetailJson { Self { name: package.pkg_name.clone(), family: package.pkg_family.clone(), - pkg_id: package.pkg_id.clone(), repo: package.repo_name.clone(), version: package.version.clone(), description: package.description.clone(), @@ -156,7 +150,6 @@ impl From<&Package> for PackageDetailJson { pub struct UpdateJson { pub name: String, pub family: Option, - pub pkg_id: Option, pub repo: String, pub current_version: String, pub new_version: String, @@ -169,7 +162,6 @@ impl From<&UpdateInfo> for UpdateJson { Self { name: update.pkg_name.clone(), family: package.pkg_family.clone(), - pkg_id: package.pkg_id.clone(), repo: update.repo_name.clone(), current_version: update.current_version.clone(), new_version: update.new_version.clone(), From ef7ac1d2a83efe7c15b410b603d79f3698d2f593 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sun, 9 Aug 2026 23:09:06 +0545 Subject: [PATCH 16/19] docs(registry): stop listing pkg_id as a required field --- crates/soar-registry/src/package.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 76ea13648..615614d9d 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 From 916c3b380de209492225b176b0494a9544580468 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 10 Aug 2026 10:24:18 +0545 Subject: [PATCH 17/19] feat(cli): describe how soar acts system wide --- crates/soar-cli/src/plugin_manifest.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 3335a3f49..16024ea01 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -46,6 +46,13 @@ version = "{version}" # 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"] @@ -120,13 +127,6 @@ label = "Portable dirs" type = "path_list" section = "Paths" -[[config]] -key = "ghcr_concurrency" -label = "GHCR concurrency" -type = "number" -default = 8 -section = "Advanced" - [[config]] key = "sync_interval" label = "Sync interval" From 9be1e21d370b67a443d8ec50f0e08f3158aade36 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 10 Aug 2026 12:09:01 +0545 Subject: [PATCH 18/19] feat(cli): report who maintains a package --- crates/soar-cli/src/json_output.rs | 8 ++++++++ crates/soar-cli/src/plugin_manifest.rs | 10 ++++++++++ crates/soar-operations/src/search.rs | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs index b86f50a3e..aa1227050 100644 --- a/crates/soar-cli/src/json_output.rs +++ b/crates/soar-cli/src/json_output.rs @@ -121,6 +121,9 @@ pub struct PackageDetailJson { pub notes: Vec, pub download_url: String, pub build_date: Option, + /// Written as a person would read them, since that is all a frontend + /// does with them. + pub maintainers: Vec, } impl From<&Package> for PackageDetailJson { @@ -141,6 +144,11 @@ impl From<&Package> for PackageDetailJson { 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(), } } } diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index 16024ea01..e294224ff 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -163,6 +163,16 @@ 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[*]" }} diff --git a/crates/soar-operations/src/search.rs b/crates/soar-operations/src/search.rs index b26334247..c1e2ba8c9 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -348,6 +348,30 @@ 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. From a226f911260003aa9c3dd54c1a9a5e347aa51b1f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 10 Aug 2026 16:17:13 +0545 Subject: [PATCH 19/19] refactor(cli): tidy the json output pass --- crates/soar-cli/src/json_output.rs | 30 ++--- crates/soar-cli/src/logging.rs | 5 +- crates/soar-cli/src/main.rs | 9 +- crates/soar-cli/src/plugin_manifest.rs | 149 ++++++++++++------------- crates/soar-cli/src/repo.rs | 7 +- crates/soar-cli/src/update.rs | 2 +- crates/soar-db/src/connection.rs | 4 +- crates/soar-events/src/sink.rs | 45 ++++---- crates/soar-operations/src/search.rs | 5 +- 9 files changed, 115 insertions(+), 141 deletions(-) diff --git a/crates/soar-cli/src/json_output.rs b/crates/soar-cli/src/json_output.rs index aa1227050..c2ba57546 100644 --- a/crates/soar-cli/src/json_output.rs +++ b/crates/soar-cli/src/json_output.rs @@ -1,10 +1,8 @@ //! The shapes `--json` reports for the query commands. //! -//! These are written out rather than derived from the internal models on -//! purpose. Anything a caller can read is a contract soar has to keep, and the -//! models carry fields that exist for the installer's benefit and would be -//! awkward to promise. Adding a field here is safe; a model gaining one is not -//! meant to change the output. +//! 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; @@ -25,8 +23,7 @@ pub struct PackageJson { pub pkg_type: Option, pub size: Option, pub installed: bool, - /// Other versions the repository publishes, newest first. Only the newest - /// is reported above, so these say what is not being shown. + /// Other versions the repository publishes, newest first. pub other_versions: Vec, } @@ -76,11 +73,10 @@ pub struct InstalledJson { pub pkg_type: Option, pub installed_path: String, pub installed_date: String, - /// What the package occupies on disk, which is not the download size. + /// Size on disk, which is not the download size. pub disk_size: u64, pub pinned: bool, - /// False when the install did not finish, so the package is on disk but - /// not usable. + /// False when the install did not finish. pub healthy: bool, } @@ -112,7 +108,7 @@ pub struct PackageDetailJson { pub description: String, pub pkg_type: Option, pub size: Option, - /// blake3, which is what a download is verified against. + /// blake3, as a download is verified against. pub checksum: Option, pub homepages: Vec, pub source_urls: Vec, @@ -121,8 +117,7 @@ pub struct PackageDetailJson { pub notes: Vec, pub download_url: String, pub build_date: Option, - /// Written as a person would read them, since that is all a frontend - /// does with them. + /// Formatted for display. pub maintainers: Vec, } @@ -200,8 +195,7 @@ impl From<&Repository> for RepositoryJson { } } -/// Where soar keeps everything, so a frontend can read and write the same -/// files rather than guessing at their locations. +/// Where soar keeps its files, so a frontend can read and write the same ones. #[derive(Serialize)] pub struct EnvJson { pub config: String, @@ -269,8 +263,7 @@ impl ApplyDiffJson { } } -/// What a command returns, wrapped so fields can be added later without -/// changing the shape a caller already reads. +/// Wraps a listing so fields can be added without changing the shape. #[derive(Serialize)] pub struct Listing { pub items: Vec, @@ -287,9 +280,6 @@ impl Listing { } /// Write a result to stdout as a single JSON document. -/// -/// Query commands answer once, so unlike the event stream this is one object -/// rather than a line per record. pub fn emit(value: &T) { if let Ok(json) = serde_json::to_string(value) { println!("{json}"); diff --git a/crates/soar-cli/src/logging.rs b/crates/soar-cli/src/logging.rs index 21a8d7299..567b3dd67 100644 --- a/crates/soar-cli/src/logging.rs +++ b/crates/soar-cli/src/logging.rs @@ -58,9 +58,8 @@ where /// Chooses which stream log records go to. /// -/// Normally informational output belongs on stdout with everything else on -/// stderr. When soar is emitting a machine-readable event stream, stdout -/// carries only that stream, so logs move aside entirely. +/// 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, } diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index a6213b897..52e5265d2 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -69,10 +69,8 @@ pub fn create_context() -> (SoarContext, Option) { } /// Build the context, putting the event stream where the command leaves room -/// for it. -/// -/// A command that answers with one JSON document keeps stdout for the answer, -/// so anything it does on the way there is reported beside it instead. +/// 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(); @@ -192,8 +190,7 @@ async fn handle_cli() -> SoarResult<()> { if args.json { *utils::EVENT_STREAM.write().unwrap() = true; - // The rendered progress display writes to the same stream the events - // go to, so only one of them can have it. + // The progress display writes to the same stream as the events. *utils::PROGRESS.write().unwrap() = false; } diff --git a/crates/soar-cli/src/plugin_manifest.rs b/crates/soar-cli/src/plugin_manifest.rs index e294224ff..2746dff62 100644 --- a/crates/soar-cli/src/plugin_manifest.rs +++ b/crates/soar-cli/src/plugin_manifest.rs @@ -1,50 +1,24 @@ -//! The adapter manifest describing how to drive this soar. +//! 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 inside the -//! binary means it always matches the binary that emitted it, which is the -//! whole point: a frontend linking soar as a library can fall out of step with -//! the soar the user actually runs, and this cannot. -//! -//! The shape is the one any package manager is described in, so a frontend has -//! a single code path and soar is not a special case. +//! 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 this describes itself in. -/// -/// A reader should accept this and the version before it, and refuse anything -/// newer rather than guessing at fields it does not know. +/// The manifest format. A reader should refuse a version it does not know. const SCHEMA_VERSION: u32 = 1; -/// Describes the commands as this build actually accepts them. -/// -/// `{selector}`, `{name}`, `{query}` and `{version}` are the only -/// substitutions. `selector` says how to build the first of those out of a -/// package's fields, so two projects publishing the same command name stay -/// told apart. -/// -/// The settings are described here too, so a frontend offering them does not -/// have to carry its own idea of what soar can be configured with. `profiles` -/// are the ones this machine has, which is why the description is generated -/// rather than written down somewhere. -pub fn manifest(profiles: &[String]) -> String { - let version = env!("CARGO_PKG_VERSION"); - let profile_options = profiles - .iter() - .map(|profile| format!("{profile:?}")) - .collect::>() - .join(", "); - - format!( - r#"# Generated by `soar plugin-manifest`. This describes the soar that +/// 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} +schema_version = @schema_version@ id = "soar" name = "Soar" -version = "{version}" +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}}"] +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 @@ -56,7 +30,7 @@ elevate = true [detect] command = "soar" version = ["--version"] -min_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 @@ -137,7 +111,7 @@ section = "Advanced" key = "default_profile" label = "Default profile" type = "select" -options = [{profile_options}] +options = [@profiles@] default = "default" section = "Advanced" @@ -145,23 +119,23 @@ section = "Advanced" # 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" }} +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" }} +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" }} +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" }} +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. @@ -175,20 +149,20 @@ 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" }} +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" }} +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" }} +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. @@ -196,62 +170,79 @@ fields = {{ config = "config", packages_config = "packages_config", bin = "bin", # Whether they are actually removed is decided when applying. [ops.apply_check] args = ["--json", "apply", "--dry-run", "--prune"] -output = {{ format = "json" }} +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" }} +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" }} +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" }} +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" }} +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" }} +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" }} +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" }} +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" }} +args = ["repo", "add", "{name}", "{url}"] +output = { format = "ndjson" } [ops.remove_repo] -args = ["repo", "remove", "{{name}}"] -output = {{ format = "ndjson" }} +args = ["repo", "remove", "{name}"] +output = { format = "ndjson" } [ops.set_repo_enabled] -args = ["repo", "update", "{{name}}", "--enabled", "{{enabled}}"] -output = {{ format = "ndjson" }} -"# - ) +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)] diff --git a/crates/soar-cli/src/repo.rs b/crates/soar-cli/src/repo.rs index 6e9b89f74..b54d9ae03 100644 --- a/crates/soar-cli/src/repo.rs +++ b/crates/soar-cli/src/repo.rs @@ -63,11 +63,8 @@ pub fn handle_repo_action(ctx: &SoarContext, action: RepoAction) -> SoarResult<( let config = soar_config::config::get_config(); if event_stream_enabled() { - let items: Vec = config - .repositories - .iter() - .map(RepositoryJson::from) - .collect(); + let items: Vec = + config.repositories.iter().map(Into::into).collect(); json_output::emit(&Listing::new(items, config.repositories.len())); return Ok(()); } diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index d9918bc83..d3fd469c0 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -47,7 +47,7 @@ pub async fn update_packages( /// 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(UpdateJson::from).collect(); + let items: Vec = updates.iter().map(Into::into).collect(); json_output::emit(&Listing::new(items, updates.len())); return Ok(()); } diff --git a/crates/soar-db/src/connection.rs b/crates/soar-db/src/connection.rs index 8e3a31a6a..cc638c8c4 100644 --- a/crates/soar-db/src/connection.rs +++ b/crates/soar-db/src/connection.rs @@ -15,8 +15,8 @@ use crate::migration::{apply_migrations, migrate_json_to_jsonb, DbType}; /// How long to wait for another process to let go of the database. /// -/// SQLite gives up the moment it finds a lock unless it is told otherwise, so -/// without this two soar processes running at once fail rather than queue. +/// 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. diff --git a/crates/soar-events/src/sink.rs b/crates/soar-events/src/sink.rs index 82afefb44..8ba089300 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 { @@ -72,40 +78,36 @@ impl EventSink for CollectorSink { /// Writes each event as one JSON object per line. /// -/// This is the shape a frontend driving soar over a pipe reads: a line is a -/// complete event, so a reader never has to buffer for a closing bracket, and -/// a stream cut short mid-operation still parses up to the last full line. -pub struct JsonLinesSink { - writer: std::sync::Mutex, +/// 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 { +impl JsonLinesSink { pub fn new(writer: W) -> Self { Self { - writer: std::sync::Mutex::new(writer), + writer: Mutex::new(writer), } } } -impl JsonLinesSink { - /// A sink writing to stdout, which is where a frontend expects the stream. +impl JsonLinesSink { + /// Writes to stdout, where a frontend expects the stream. pub fn stdout() -> Self { - Self::new(std::io::stdout()) + Self::new(io::stdout()) } } -impl JsonLinesSink { - /// A sink writing beside the answer rather than into it. - /// - /// A command answering with one JSON document cannot carry a stream on the - /// same output, since a reader expecting a document would find a second - /// thing after it. +impl JsonLinesSink { + /// Writes beside the answer, for a command whose stdout carries one JSON + /// document. pub fn stderr() -> Self { - Self::new(std::io::stderr()) + Self::new(io::stderr()) } } -impl EventSink for JsonLinesSink { +impl EventSink for JsonLinesSink { fn emit(&self, event: SoarEvent) { let Ok(line) = serde_json::to_string(&event) else { return; @@ -113,8 +115,7 @@ impl EventSink for JsonLinesSink { let Ok(mut writer) = self.writer.lock() else { return; }; - // Flushed per event: a frontend rendering progress needs it now, not - // when the buffer happens to fill. + // 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/search.rs b/crates/soar-operations/src/search.rs index c1e2ba8c9..96dd872e7 100644 --- a/crates/soar-operations/src/search.rs +++ b/crates/soar-operations/src/search.rs @@ -348,10 +348,9 @@ pub async fn query_package(ctx: &SoarContext, query_str: &str) -> SoarResult