Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7989560
feat(events): emit the event stream as json lines
QaidVoid Aug 3, 2026
9f59b04
feat(cli): report list, search, query and info as json
QaidVoid Aug 3, 2026
881b633
feat(cli): describe how to drive soar with plugin-manifest
QaidVoid Aug 3, 2026
e8d0395
fix(cli): keep a json answer alone on stdout
QaidVoid Aug 3, 2026
ff79c00
feat(cli): name a package by its family in the manifest
QaidVoid Aug 3, 2026
6b119f3
feat(cli): answer updates, repos, paths and diffs as json
QaidVoid Aug 3, 2026
8db4ef2
feat(events): say what applying the configuration did
QaidVoid Aug 3, 2026
a5d0bb2
feat(cli): describe soar's settings in the manifest
QaidVoid Aug 3, 2026
8b6c2f9
fix(db): wait for a busy database instead of failing
QaidVoid Aug 3, 2026
35d916f
fix(cli): exit non-zero when a command fails
QaidVoid Aug 3, 2026
6733153
feat(cli): report the stage each operation reaches
QaidVoid Aug 3, 2026
46d9a5e
feat(cli): report the build date and richer package fields
QaidVoid Aug 3, 2026
d4b9502
feat(cli): report the package type in query output
QaidVoid Aug 3, 2026
1a4e1ae
feat(cli): map the source url in the manifest
QaidVoid Aug 3, 2026
e82c983
refactor(cli): drop the unused pkg_id from json output
QaidVoid Aug 9, 2026
ef7ac1d
docs(registry): stop listing pkg_id as a required field
QaidVoid Aug 9, 2026
916c3b3
feat(cli): describe how soar acts system wide
QaidVoid Aug 10, 2026
9be1e21
feat(cli): report who maintains a package
QaidVoid Aug 10, 2026
a226f91
refactor(cli): tidy the json output pass
PitamberTiwari Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions crates/soar-cli/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(());
}
Expand All @@ -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(());
Comment on lines +40 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include pending version updates in the JSON diff.

Line 41 emits ApplyDiffJson, but that model omits pending_version_updates. execute_apply changes those declared versions before package operations. A frontend can therefore report a no-op when apply will modify packages.toml.

Add the pending version updates to ApplyDiffJson and the emitted schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/soar-cli/src/apply.rs` around lines 40 - 42, Update ApplyDiffJson and
its emitted schema to include pending_version_updates, then pass the updates
produced by execute_apply into ApplyDiffJson::new in the dry_run and
event_stream_enabled path. Ensure the JSON diff reflects declared version
changes made before package operations.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep stdout valid NDJSON during confirmation.

When JSON mode runs without --yes, the normal apply path reaches the direct print! confirmation prompt. That plain-text prompt corrupts the event stream on stdout.

Write interactive prompts to stderr, or reject interactive JSON mode and require --yes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/soar-cli/src/apply.rs` around lines 40 - 42, Update the confirmation
flow around ApplyDiffJson::new so JSON mode never writes the interactive prompt
to stdout; route the prompt to stderr while preserving valid NDJSON event
output, or reject interactive JSON mode unless --yes is supplied.

}

display_diff(&diff, prune);

if !diff.has_changes() && !diff.has_toml_updates() {
Expand Down
8 changes: 8 additions & 0 deletions crates/soar-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
287 changes: 287 additions & 0 deletions crates/soar-cli/src/json_output.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub repo: String,
pub version: String,
pub description: String,
pub pkg_type: Option<String>,
pub size: Option<u64>,
pub installed: bool,
/// Other versions the repository publishes, newest first.
pub other_versions: Vec<String>,
}

impl PackageJson {
fn new(package: &Package, installed: bool, other_versions: Vec<String>) -> 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<String>,
pub repo: String,
pub version: String,
pub pkg_type: Option<String>,
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<String>,
pub repo: String,
pub version: String,
pub description: String,
pub pkg_type: Option<String>,
pub size: Option<u64>,
/// blake3, as a download is verified against.
pub checksum: Option<String>,
pub homepages: Vec<String>,
pub source_urls: Vec<String>,
pub licenses: Vec<String>,
pub categories: Vec<String>,
pub notes: Vec<String>,
pub download_url: String,
pub build_date: Option<String>,
/// Formatted for display.
pub maintainers: Vec<String>,
}

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<String>,
pub repo: String,
pub current_version: String,
pub new_version: String,
pub size: Option<u64>,
}

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<String>,
pub repo: String,
pub version: String,
/// The version on disk now, for a package being replaced.
pub current_version: Option<String>,
}

/// What applying the declarative configuration would do.
#[derive(Serialize)]
pub struct ApplyDiffJson {
pub to_install: Vec<ApplyChangeJson>,
pub to_update: Vec<ApplyChangeJson>,
pub to_remove: Vec<ApplyChangeJson>,
pub in_sync: Vec<String>,
pub not_found: Vec<String>,
}

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<T: Serialize> {
pub items: Vec<T>,
pub total: usize,
}

impl<T: Serialize> Listing<T> {
pub fn new(items: Vec<T>, total: usize) -> Self {
Self {
items,
total,
}
}
}

/// Write a result to stdout as a single JSON document.
pub fn emit<T: Serialize>(value: &T) {
if let Ok(json) = serde_json::to_string(value) {
println!("{json}");
}
}
Loading
Loading