From 4e945e5611e4417a3442269434c93d3a40ea306c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:38:04 +0000
Subject: [PATCH 01/11] Bump the github-actions group with 3 updates
Updates the requirements on [actions/checkout](https://github.com/actions/checkout), [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) and [actions/setup-python](https://github.com/actions/setup-python) to permit the latest version.
Updates `actions/checkout` from 4.4.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.4.0...3d3c42e5aac5ba805825da76410c181273ba90b1)
Updates `dtolnay/rust-toolchain` to 18f0487477426dce244608110aa2660c2893deb7
- [Release notes](https://github.com/dtolnay/rust-toolchain/releases)
- [Commits](https://github.com/dtolnay/rust-toolchain/commits/18f0487477426dce244608110aa2660c2893deb7)
Updates `actions/setup-python` from 5.6.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: dtolnay/rust-toolchain
dependency-version: 18f0487477426dce244608110aa2660c2893deb7
dependency-type: direct:production
dependency-group: github-actions
- dependency-name: actions/setup-python
dependency-version: 7.1.0
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] gh):
VERSION=1.0.1
+VERSION=1.0.2
ASSET="xfetch-${VERSION}-x86_64-unknown-linux-gnu.tar.gz"
BASE="https://github.com/xfetch-cli/xfetch/releases/download/v${VERSION}"
diff --git a/install-prebuilt.ps1 b/install-prebuilt.ps1
index 8812686..7cc44f0 100644
--- a/install-prebuilt.ps1
+++ b/install-prebuilt.ps1
@@ -5,7 +5,7 @@
#
# Usage:
# irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.ps1 | iex
-# .\install-prebuilt.ps1 -Version 1.0.1 -BinDir C:\tools\bin
+# .\install-prebuilt.ps1 -Version 1.0.2 -BinDir C:\tools\bin
# .\install-prebuilt.ps1 -NoChecksum -NoPath
param(
diff --git a/install-prebuilt.sh b/install-prebuilt.sh
index 7b283dd..40cb54a 100755
--- a/install-prebuilt.sh
+++ b/install-prebuilt.sh
@@ -3,7 +3,7 @@
# Prebuilt installer — downloads a precompiled binary from GitHub Releases.
# Fast: no Rust toolchain, no compilation. Requires only curl (and tar/unzip).
# Usage: curl -fsSL https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.sh | bash
-# bash install-prebuilt.sh --version 1.0.1
+# bash install-prebuilt.sh --version 1.0.2
# bash install-prebuilt.sh --prefix /usr/local --yes
set -euo pipefail
@@ -174,7 +174,7 @@ Options:
-y, --yes Automatic yes to all prompts
-s, --skip-config Skip generating the default config
-q, --quiet Quiet mode (minimal output)
- --version Install a specific version (e.g. 1.0.1); default: latest release
+ --version Install a specific version (e.g. 1.0.2); default: latest release
--require-signature Abort when cosign is unavailable or the signature bundle is missing
--allow-unsigned Accept a signed release that is missing SHA256SUMS.bundle
(SHA-256 only; not recommended)
@@ -200,7 +200,7 @@ Examples:
curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash
# Install a specific version
- curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.1
+ curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.2
# Non-interactive install
bash install-prebuilt.sh --yes
@@ -255,7 +255,7 @@ parse_args() {
# ──────────────────────────────────────────────
# A release version must be a dotted numeric version, optionally with a
-# prerelease suffix (1.0.1, 1.0.1-rc1, 1.0.1-rc.2). Anything else is rejected
+# prerelease suffix (1.0.2, 1.0.2-rc1, 1.0.2-rc.2). Anything else is rejected
# instead of being silently coerced.
validate_version() {
printf '%s\n' "${1:-}" \
@@ -268,7 +268,7 @@ resolve_asset_url() {
release_url="${REPO_API}/releases/latest"
else
validate_version "${VERSION}" \
- || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.1."
+ || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.2."
release_url="${REPO_API}/releases/tags/v${VERSION}"
fi
diff --git a/src/info/hardware.rs b/src/info/hardware.rs
index 13c0282..aac9f9f 100644
--- a/src/info/hardware.rs
+++ b/src/info/hardware.rs
@@ -69,13 +69,15 @@ mod tests {
#[test]
fn test_get_memory_info() {
- let mem = get_memory_info(&System::new_all());
+ let sys = System::new_all().expect("system information");
+ let mem = get_memory_info(&sys);
assert!(mem.contains("GiB"), "memory should show GiB");
}
#[test]
fn test_get_swap_info() {
- let swap = get_swap_info(&System::new_all());
+ let sys = System::new_all().expect("system information");
+ let swap = get_swap_info(&sys);
assert!(swap.contains("GiB") || swap == "0 B / 0 B (0%)");
}
}
diff --git a/src/info/mod.rs b/src/info/mod.rs
index 4ddbbd6..5138d08 100644
--- a/src/info/mod.rs
+++ b/src/info/mod.rs
@@ -121,9 +121,9 @@ impl Info {
let networks_h =
needs_network(&needed).then(|| s.spawn(Networks::new_with_refreshed_list));
(
- sys_h.and_then(|h| h.join().ok()),
- disks_h.and_then(|h| h.join().ok()),
- networks_h.and_then(|h| h.join().ok()),
+ sys_h.and_then(|h| h.join().ok()).and_then(|r| r.ok()),
+ disks_h.and_then(|h| h.join().ok()).and_then(|r| r.ok()),
+ networks_h.and_then(|h| h.join().ok()).and_then(|r| r.ok()),
)
});
diff --git a/src/info/platform/linux/network.rs b/src/info/platform/linux/network.rs
index 6b4ff21..3d16083 100644
--- a/src/info/platform/linux/network.rs
+++ b/src/info/platform/linux/network.rs
@@ -41,7 +41,7 @@ pub fn get_local_ip_info(networks: &Networks) -> String {
if let std::net::IpAddr::V4(ipv4) = ip.addr
&& !ipv4.is_loopback()
{
- if is_virtual(name) {
+ if is_virtual(name.to_string_lossy().as_ref()) {
virtual_fallback.get_or_insert_with(|| ipv4.to_string());
} else {
return ipv4.to_string();
diff --git a/src/info/platform/macos/network.rs b/src/info/platform/macos/network.rs
index 0419448..c1df7ca 100644
--- a/src/info/platform/macos/network.rs
+++ b/src/info/platform/macos/network.rs
@@ -42,7 +42,7 @@ pub fn get_local_ip_info(networks: &Networks) -> String {
if let std::net::IpAddr::V4(ipv4) = ip.addr
&& !ipv4.is_loopback()
{
- if is_virtual(name) {
+ if is_virtual(name.to_string_lossy().as_ref()) {
virtual_fallback.get_or_insert_with(|| ipv4.to_string());
} else {
return ipv4.to_string();
diff --git a/src/info/platform/macos/version.rs b/src/info/platform/macos/version.rs
index e9320d0..7e928e3 100644
--- a/src/info/platform/macos/version.rs
+++ b/src/info/platform/macos/version.rs
@@ -42,7 +42,7 @@ pub fn macos_version_id(version: &str) -> Option<&'static str> {
/// mapped.
pub fn detect_os_ids() -> (String, Vec) {
let mut ids = Vec::new();
- if let Some(v) = sysinfo::System::os_version()
+ if let Ok(v) = sysinfo::System::os_version()
&& let Some(specific) = macos_version_id(&v)
{
ids.push(specific.to_string());
diff --git a/src/info/platform/mod.rs b/src/info/platform/mod.rs
index a1bf79a..bf2d12f 100644
--- a/src/info/platform/mod.rs
+++ b/src/info/platform/mod.rs
@@ -62,7 +62,7 @@ pub fn detect_os_ids() -> (String, Vec) {
#[cfg(target_os = "windows")]
{
let mut ids = Vec::new();
- if let Some(v) = sysinfo::System::os_version()
+ if let Ok(v) = sysinfo::System::os_version()
&& let Some(specific) = windows::version::windows_version_id(&v)
{
ids.push(specific.to_string());
diff --git a/src/info/platform/windows/network.rs b/src/info/platform/windows/network.rs
index e0152fa..847c0d8 100644
--- a/src/info/platform/windows/network.rs
+++ b/src/info/platform/windows/network.rs
@@ -41,7 +41,7 @@ pub fn get_local_ip_info(networks: &Networks) -> String {
if let std::net::IpAddr::V4(ipv4) = ip.addr
&& !ipv4.is_loopback()
{
- if is_virtual(name) {
+ if is_virtual(name.to_string_lossy().as_ref()) {
virtual_fallback.get_or_insert_with(|| ipv4.to_string());
} else {
return ipv4.to_string();
diff --git a/src/info/platform/windows/shell.rs b/src/info/platform/windows/shell.rs
index 95c195b..0ab950b 100644
--- a/src/info/platform/windows/shell.rs
+++ b/src/info/platform/windows/shell.rs
@@ -43,7 +43,7 @@ fn shell_label(name: &str) -> String {
/// process chain. `None` when no known shell is found (e.g. launched by
/// Explorer); the caller falls back to env detection.
pub fn detect_shell_name() -> Option {
- let mut sys = System::new();
+ let mut sys = System::new().ok()?;
sys.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::nothing());
let current = sysinfo::get_current_pid().ok()?;
let mut proc = sys.process(current)?;
diff --git a/src/info/system.rs b/src/info/system.rs
index ef890ed..1608978 100644
--- a/src/info/system.rs
+++ b/src/info/system.rs
@@ -9,7 +9,7 @@ use ureq::Agent;
const PUBLIC_IP_HOSTS: [&str; 3] = ["ifconfig.me", "api.ipify.org", "icanhazip.com"];
pub fn get_os_info() -> String {
- let name = System::name().unwrap_or_else(super::unknown);
+ let name = System::name().unwrap_or_else(|_| super::unknown());
let version = System::os_version().unwrap_or_default();
let arch = std::env::consts::ARCH;
if version.is_empty() {
@@ -20,15 +20,15 @@ pub fn get_os_info() -> String {
}
pub fn get_kernel_info() -> String {
- System::kernel_version().unwrap_or_else(super::unknown)
+ System::kernel_version().unwrap_or_else(|_| super::unknown())
}
pub fn get_host_name() -> String {
- System::host_name().unwrap_or_else(super::unknown)
+ System::host_name().unwrap_or_else(|_| super::unknown())
}
pub fn get_uptime_info() -> String {
- let uptime = System::uptime();
+ let uptime = System::uptime().unwrap_or(0);
let hours = uptime / 3600;
let mins = (uptime % 3600) / 60;
let hour_label = if hours == 1 { "hour" } else { "hours" };
@@ -134,9 +134,9 @@ pub fn get_network_interfaces_info(networks: &Networks) -> String {
.collect();
if !ips.is_empty() {
let label = if mac.is_empty() {
- format!("{} {}", name, ips.join(", "))
+ format!("{} {}", name.to_string_lossy(), ips.join(", "))
} else {
- format!("{} [{}] {}", name, mac, ips.join(", "))
+ format!("{} [{}] {}", name.to_string_lossy(), mac, ips.join(", "))
};
parts.push(label);
}
From 94624fafe999f26c0bdbacf5c85c0ddcf98a09bd Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 11:36:07 +0100
Subject: [PATCH 03/11] chore(deps): update compatible crates and migrate to
ureq 3
- Refresh the semver-compatible dependency graph and bump the manifest
floors for clap, console, image, serde, serde_json and wat.
- Migrate the updater, public IP probe and wasm HTTP host from ureq 2.12
to 3.5.1, including config builders, status handling and body readers.
- Add a local HTTP success test for the wasm host operation.
---
CHANGELOG.md | 7 +++
Cargo.lock | 119 +++++++++++++++++++++++++++---------------
Cargo.toml | 14 ++---
src/info/system.rs | 24 ++++-----
src/update/github.rs | 87 +++++++++++++++++-------------
src/wasm/host/http.rs | 106 ++++++++++++++++++++++++++++---------
src/wasm/install.rs | 11 ++--
7 files changed, 243 insertions(+), 125 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed384cd..bd59880 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## Unreleased
+
+### Dependencies
+
+- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`.
+- Migrated from `ureq` 2.12 to 3.5.1. The updater, public IP probe and wasm HTTP host now use `Agent::config_builder`, `Error::StatusCode`, `Body` readers and request-level timeouts. No user-facing behavior change is intended.
+
## 2026-11-03 — v1.0.2
### Security
diff --git a/Cargo.lock b/Cargo.lock
index 800ff18..24c81f2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -176,9 +176,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
-version = "1.10.0"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
+checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -473,9 +473,9 @@ checksum = "6f34b169b8cbbaed2b4995205f1d02d7eac4b2771df5479255fe5483e1334a39"
[[package]]
name = "crc32fast"
-version = "1.5.3"
+version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
+checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
@@ -698,9 +698,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
-version = "0.8.42"
+version = "0.8.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
dependencies = [
"cfg-if",
"core_detect",
@@ -948,6 +948,22 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -1207,9 +1223,9 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.34"
+version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "mach2"
@@ -1647,9 +1663,9 @@ dependencies = [
[[package]]
name = "rustix"
-version = "1.1.5"
+version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
@@ -2146,9 +2162,9 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
-version = "0.2.2"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "untrusted"
@@ -2158,18 +2174,31 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
-version = "2.12.1"
+version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+checksum = "e7be76686bdf7198002062423ca0d8cf9b75a27c8e1de804513b97e97b555d9d"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"flate2",
"log",
- "once_cell",
+ "percent-encoding",
"rustls",
"rustls-pki-types",
- "url",
- "webpki-roots 0.26.11",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
]
[[package]]
@@ -2184,11 +2213,20 @@ dependencies = [
"serde",
]
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
-version = "1.0.4"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0"
+dependencies = [
+ "utf_types",
+]
[[package]]
name = "utf8parse"
@@ -2196,6 +2234,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "utf_types"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -2235,12 +2279,12 @@ dependencies = [
[[package]]
name = "wasm-encoder"
-version = "0.262.0"
+version = "0.263.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
+checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f"
dependencies = [
"leb128fmt",
- "wasmparser 0.262.0",
+ "wasmparser 0.263.0",
]
[[package]]
@@ -2270,9 +2314,9 @@ dependencies = [
[[package]]
name = "wasmparser"
-version = "0.262.0"
+version = "0.263.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
+checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb"
dependencies = [
"bitflags",
"indexmap",
@@ -2561,33 +2605,24 @@ dependencies = [
[[package]]
name = "wast"
-version = "262.0.0"
+version = "263.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23"
+checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
- "wasm-encoder 0.262.0",
+ "wasm-encoder 0.263.0",
]
[[package]]
name = "wat"
-version = "1.262.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1"
-dependencies = [
- "wast 262.0.0",
-]
-
-[[package]]
-name = "webpki-roots"
-version = "0.26.11"
+version = "1.263.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7"
dependencies = [
- "webpki-roots 1.0.11",
+ "wast 263.0.0",
]
[[package]]
@@ -2932,9 +2967,9 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "xattr"
-version = "1.6.1"
+version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
dependencies = [
"libc",
"rustix",
diff --git a/Cargo.toml b/Cargo.toml
index a2172da..02615f8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,17 +23,17 @@ flate2 = { version = "1", optional = true }
semver = { version = "1", optional = true }
sha2 = "0.11"
tar = { version = "0.4", optional = true }
-clap = { version = "4.5.54", features = ["derive"] }
-console = "0.16.2"
+clap = { version = "4.6.7", features = ["derive"] }
+console = "0.16.6"
crossterm = "0.29.0"
dirs = "7.0.0"
-image = { version = "0.25.9", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "webp", "tiff"] }
+image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "webp", "tiff"] }
json_comments = "0.2.2"
libc = "0.2"
-serde = { version = "1.0.228", features = ["derive"] }
-serde_json = "1.0.148"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.154"
sysinfo = "0.40.1"
-ureq = { version = "2.12", features = ["tls"] }
+ureq = "3.5.1"
url = { version = "2", optional = true }
viuer = { version = "0.11.0", default-features = false }
wasmtime = { version = "50.0.1", optional = true, default-features = false, features = [
@@ -69,4 +69,4 @@ windows-sys = { version = "0.61", features = [
] }
[dev-dependencies]
-wat = "1"
+wat = "1.263.0"
diff --git a/src/info/system.rs b/src/info/system.rs
index 1608978..debb97e 100644
--- a/src/info/system.rs
+++ b/src/info/system.rs
@@ -1,4 +1,3 @@
-use std::io::Read;
use std::net::IpAddr;
use std::sync::LazyLock;
use std::thread;
@@ -61,11 +60,12 @@ pub fn get_ipv6_info(networks: &Networks) -> String {
/// No redirects: the IP endpoints serve the address directly, and a
/// redirect to plaintext HTTP would silently downgrade the request.
static AGENT: LazyLock = LazyLock::new(|| {
- ureq::AgentBuilder::new()
- .timeout_connect(Duration::from_secs(3))
- .timeout_read(Duration::from_secs(5))
- .redirects(0)
- .build()
+ let config = Agent::config_builder()
+ .timeout_connect(Some(Duration::from_secs(3)))
+ .timeout_per_read(Some(Duration::from_secs(5)))
+ .max_redirects(0)
+ .build();
+ config.into()
});
/// Validates the response body is a real IP address. Stricter than the old
@@ -81,12 +81,12 @@ fn parse_public_ip(body: &str) -> Option {
fn fetch_public_ip_from(host: &str) -> Option {
// Cap the body at 64 bytes: a public IP is at most 45 chars.
- let response = AGENT.get(&format!("https://{host}/")).call().ok()?;
- let mut body = String::new();
- response
- .into_reader()
- .take(64)
- .read_to_string(&mut body)
+ let mut response = AGENT.get(&format!("https://{host}/")).call().ok()?;
+ let body = response
+ .body_mut()
+ .with_config()
+ .limit(64)
+ .read_to_string()
.ok()?;
parse_public_ip(&body).map(|ip| ip.to_string())
}
diff --git a/src/update/github.rs b/src/update/github.rs
index 372e8fc..c33ff16 100644
--- a/src/update/github.rs
+++ b/src/update/github.rs
@@ -45,6 +45,11 @@ struct WireAsset {
size: u64,
}
+struct HttpResponse {
+ content_length: Option,
+ body: ureq::Body,
+}
+
/// Small HTTP client with xfetch's user agent and optional auth.
pub struct Client {
agent: ureq::Agent,
@@ -53,10 +58,12 @@ pub struct Client {
impl Client {
pub fn new() -> Result {
- let agent = ureq::AgentBuilder::new()
+ let agent: ureq::Agent = ureq::Agent::config_builder()
.user_agent(concat!("xfetch/", env!("CARGO_PKG_VERSION")))
- .redirects(5)
- .build();
+ .max_redirects(5)
+ .http_status_as_error(false)
+ .build()
+ .into();
let token = std::env::var("GH_TOKEN")
.or_else(|_| std::env::var("GITHUB_TOKEN"))
.ok()
@@ -66,10 +73,13 @@ impl Client {
/// Fetches a small JSON/text document with a bounded size.
pub fn get_text(&self, url: &str, cap: usize, timeout: Duration) -> Result {
- let response = self.request(url, timeout)?;
+ let mut response = self.request(url, timeout)?;
let mut body = Vec::new();
response
- .into_reader()
+ .body
+ .with_config()
+ .limit(cap as u64 + 1)
+ .reader()
.take(cap as u64 + 1)
.read_to_end(&mut body)
.map_err(|err| format!("Failed to read '{}': {}", url, err))?;
@@ -82,11 +92,8 @@ impl Client {
/// Downloads a binary asset with a bounded size.
#[cfg_attr(not(unix), allow(dead_code))]
pub fn get_bytes(&self, url: &str, cap: usize) -> Result, String> {
- let response = self.request(url, Duration::from_secs(300))?;
- let declared = response
- .header("content-length")
- .and_then(|value| value.parse::().ok())
- .unwrap_or(0);
+ let mut response = self.request(url, Duration::from_secs(300))?;
+ let declared = response.content_length.unwrap_or(0);
if declared > cap as u64 {
return Err(format!(
"Asset from '{}' is larger than the {} MiB limit",
@@ -97,7 +104,10 @@ impl Client {
let mut body = Vec::new();
response
- .into_reader()
+ .body
+ .with_config()
+ .limit(cap as u64 + 1)
+ .reader()
.take(cap as u64 + 1)
.read_to_end(&mut body)
.map_err(|err| format!("Failed to download '{}': {}", url, err))?;
@@ -108,40 +118,47 @@ impl Client {
}
/// Sends a GET with the standard headers.
- fn request(&self, url: &str, timeout: Duration) -> Result {
+ fn request(&self, url: &str, timeout: Duration) -> Result {
let mut request = self
.agent
.get(url)
- .timeout(timeout)
- .set("Accept", "application/vnd.github+json");
+ .config()
+ .timeout_global(Some(timeout))
+ .build()
+ .header("Accept", "application/vnd.github+json");
if let Some(token) = &self.token {
- request = request.set("Authorization", &format!("Bearer {}", token));
+ request = request.header("Authorization", format!("Bearer {}", token));
}
- request.call().map_err(|err| http_error(url, err))
- }
-}
-
-/// Turns a ureq error into an actionable message.
-fn http_error(url: &str, err: ureq::Error) -> String {
- match err {
- ureq::Error::Status(code, response) => {
- if code == 403 {
- format!(
+ let mut response = request
+ .call()
+ .map_err(|err| format!("Request to '{}' failed: {}", url, err))?;
+ let status = response.status().as_u16();
+ if !(200..300).contains(&status) {
+ if status == 403 {
+ return Err(format!(
"'{}' returned HTTP 403 (rate limit?). Set GH_TOKEN or try again later.",
url
- )
- } else {
- let body = response
- .into_string()
- .unwrap_or_default()
- .chars()
- .take(160)
- .collect::();
- format!("'{}' returned HTTP {}: {}", url, code, body)
+ ));
}
+ let body = response
+ .body_mut()
+ .with_config()
+ .limit(160)
+ .read_to_string()
+ .unwrap_or_default();
+ return Err(format!("'{}' returned HTTP {}: {}", url, status, body));
}
- ureq::Error::Transport(err) => format!("Request to '{}' failed: {}", url, err),
+
+ let content_length = response
+ .headers()
+ .get("content-length")
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.parse::().ok());
+ Ok(HttpResponse {
+ content_length,
+ body: response.into_body(),
+ })
}
}
diff --git a/src/wasm/host/http.rs b/src/wasm/host/http.rs
index a8122e9..0983c30 100644
--- a/src/wasm/host/http.rs
+++ b/src/wasm/host/http.rs
@@ -38,40 +38,54 @@ pub fn request(args: &Value, ctx: &HostContext) -> HostCallResult {
let mut current = Url::parse(url)
.map_err(|err| HostCallError::failed(format!("invalid URL '{}': {}", url, err)))?;
- let agent = ureq::AgentBuilder::new()
- .redirects(0)
- .timeout(timeout)
+ let config = ureq::Agent::config_builder()
+ .max_redirects(0)
+ .timeout_global(Some(timeout))
+ .http_status_as_error(false)
.build();
+ let agent: ureq::Agent = config.into();
for _ in 0..=MAX_REDIRECTS {
let started = Instant::now();
- let mut request = agent.request(&method, current.as_str());
- for (name, value) in &headers {
- request = request.set(name, value);
- }
-
+ let build = || {
+ let mut builder = ureq::http::Request::builder()
+ .method(method.as_str())
+ .uri(current.as_str());
+ for (name, value) in &headers {
+ builder = builder.header(name.as_str(), value.as_str());
+ }
+ builder
+ };
let response = match body.as_deref() {
- Some(bytes) => request.send_bytes(bytes),
- None => request.call(),
+ Some(bytes) => {
+ let request = build().body(bytes).map_err(|err| {
+ HostCallError::failed(format!("invalid HTTP request: {}", err))
+ })?;
+ agent.run(request)
+ }
+ None => {
+ let request = build().body(()).map_err(|err| {
+ HostCallError::failed(format!("invalid HTTP request: {}", err))
+ })?;
+ agent.run(request)
+ }
};
let response = match response {
Ok(response) => response,
- // Non-2xx statuses are still valid responses: return them.
- Err(ureq::Error::Status(_, response)) => response,
- Err(ureq::Error::Transport(err)) => {
- return Err(transport_error(err.to_string(), started, timeout));
- }
+ Err(err) => return Err(transport_error(err.to_string(), started, timeout)),
};
- let status = response.status();
+ let status = response.status().as_u16();
if (300..400).contains(&status) {
let location = response
- .header("location")
+ .headers()
+ .get("location")
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_string)
.ok_or_else(|| {
HostCallError::failed("redirect response without a Location header")
- })?
- .to_string();
+ })?;
let next = current.join(&location).map_err(|err| {
HostCallError::failed(format!("invalid redirect target '{}': {}", location, err))
})?;
@@ -92,20 +106,27 @@ pub fn request(args: &Value, ctx: &HostContext) -> HostCallResult {
}
/// Collects response metadata and the capped body.
-fn read_response(response: ureq::Response, ctx: &HostContext) -> HostCallResult {
- let status = response.status();
+fn read_response(
+ mut response: ureq::http::Response,
+ ctx: &HostContext,
+) -> HostCallResult {
+ let status = response.status().as_u16();
let mut headers = Vec::new();
- for name in response.headers_names() {
- for value in response.all(&name) {
- headers.push(json!([name, value]));
- }
+ for (name, value) in response.headers() {
+ headers.push(json!([
+ name.as_str(),
+ String::from_utf8_lossy(value.as_bytes()).to_string()
+ ]));
}
let cap = ctx.policy.host_call_bytes;
let mut bytes = Vec::new();
response
- .into_reader()
+ .body_mut()
+ .with_config()
+ .limit(cap as u64 + 1)
+ .reader()
.take(cap as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|err| HostCallError::failed(format!("failed to read response body: {}", err)))?;
@@ -220,4 +241,37 @@ mod tests {
HostErrorKind::Failed | HostErrorKind::Timeout
));
}
+
+ #[test]
+ fn successful_request_returns_status_headers_and_body() {
+ use std::io::{Read, Write};
+ use std::net::TcpListener;
+
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
+ let port = listener.local_addr().expect("addr").port();
+ let server = std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().expect("accept");
+ let mut buf = [0u8; 1024];
+ let _ = stream.read(&mut buf);
+ stream
+ .write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Type: text/plain\r\nX-Test: yes\r\n\r\nhello",
+ )
+ .expect("write");
+ });
+
+ let url = format!("http://127.0.0.1:{port}/");
+ let allow = format!(r#"{{ "capabilities": {{ "http": {{ "allow": ["{url}"] }} }} }}"#);
+ let ctx = context(&allow);
+ let result = request(&json!({ "url": url, "timeout_ms": 2000 }), &ctx).expect("request");
+ server.join().expect("server");
+
+ assert_eq!(result["status"], 200);
+ assert_eq!(result["body_base64"], "aGVsbG8=");
+ assert!(result["headers"].as_array().is_some_and(|headers| {
+ headers
+ .iter()
+ .any(|header| header[0] == "x-test" && header[1] == "yes")
+ }));
+ }
}
diff --git a/src/wasm/install.rs b/src/wasm/install.rs
index 8f5b7ec..62aae99 100644
--- a/src/wasm/install.rs
+++ b/src/wasm/install.rs
@@ -282,14 +282,19 @@ pub fn download_and_install(
println!("Downloading wasm {} '{}' from {}...", label, name, url);
- let response = ureq::get(url)
- .timeout(Duration::from_secs(120))
+ let mut response = ureq::get(url)
+ .config()
+ .timeout_global(Some(Duration::from_secs(120)))
+ .build()
.call()
.map_err(|err| format!("Failed to download '{}': {}", url, err))?;
let mut bytes = Vec::new();
response
- .into_reader()
+ .body_mut()
+ .with_config()
+ .limit(DOWNLOAD_LIMIT + 1)
+ .reader()
.take(DOWNLOAD_LIMIT + 1)
.read_to_end(&mut bytes)
.map_err(|err| format!("Failed to read '{}': {}", url, err))?;
From c597e50c7d1c70a7ed848f5410e7c53b7dc5b7ce Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 11:59:08 +0100
Subject: [PATCH 04/11] docs(changelog): fold dependency updates into v1.0.2
---
CHANGELOG.md | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd59880..13c6a15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,5 @@
# Changelog
-## Unreleased
-
-### Dependencies
-
-- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`.
-- Migrated from `ureq` 2.12 to 3.5.1. The updater, public IP probe and wasm HTTP host now use `Agent::config_builder`, `Error::StatusCode`, `Body` readers and request-level timeouts. No user-facing behavior change is intended.
-
## 2026-11-03 — v1.0.2
### Security
@@ -18,6 +11,8 @@
### Dependencies
- Updated `sysinfo` from 0.39.6 to 0.40.1 and adapted the fallible constructors and static getters (`System::new`, `new_all`, `new_with_specifics`, `name`, `os_version`, `kernel_version`, `host_name`, `uptime`) plus the `OsString` network interface names. No user-visible output changes.
+- Migrated from `ureq` 2.12 to 3.5.1. The updater, public IP probe and wasm HTTP host now use `Agent::config_builder`, `Error::StatusCode`, `Body` readers and request-level timeouts. A local HTTP success test covers the wasm host path. No user-facing behavior change is intended.
+- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`.
- Updated the GitHub Actions group: `actions/checkout` to 7.0.1, `actions/setup-python` to 7.1.0 and the pinned `dtolnay/rust-toolchain` commit. All actions remain pinned by commit SHA.
### Release
From 9ad98b83fe256b131d421d87d60413f5015018e8 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 14:11:20 +0100
Subject: [PATCH 05/11] feat(addons): add install guidance, info and direct
wasm options
- Add a shared addons module for installed metadata, capability summaries
and configuration snippets.
- Print the configuration snippet and declared capabilities after a
successful plugin, effect or extension install.
- Add list --verbose and info for all three addon families.
- Add --name and --manifest for direct wasm file or URL installs.
- Add command aliases for plugins, effect and extensions.
- Update the plugin, effect and extension documentation and the v1.0.2
changelog.
---
CHANGELOG.md | 7 +
docs/EFFECTS.md | 14 +-
docs/EXTENSIONS.md | 14 +-
docs/PLUGINS.md | 22 +++
src/addons/install.rs | 212 ++++++++++++++++++++++
src/addons/mod.rs | 10 ++
src/addons/model.rs | 408 ++++++++++++++++++++++++++++++++++++++++++
src/addons/report.rs | 116 ++++++++++++
src/cli.rs | 50 +++++-
src/main.rs | 229 +++++++++++++++++-------
10 files changed, 1011 insertions(+), 71 deletions(-)
create mode 100644 src/addons/install.rs
create mode 100644 src/addons/mod.rs
create mode 100644 src/addons/model.rs
create mode 100644 src/addons/report.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13c6a15..aa87d60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,13 @@
- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`.
- Updated the GitHub Actions group: `actions/checkout` to 7.0.1, `actions/setup-python` to 7.1.0 and the pinned `dtolnay/rust-toolchain` commit. All actions remain pinned by commit SHA.
+### Addon tooling
+
+- `xfetch plugin`, `xfetch effects` and `xfetch extension` installs now print the configuration snippet required to enable the installed addon, along with its declared capabilities and limits.
+- Added `list --verbose` and `info ` for plugins, effects and extensions. They report the manifest kind, runtime, version, capabilities, limits and whether the addon is enabled.
+- Direct wasm files and URLs now accept `--name` and `--manifest` on install; directory and repository installs keep using their own name and manifest.
+- Added command aliases `plugins`, `effect`, and `extensions` for the existing command families.
+
### Release
- Bumped the crate version to 1.0.2 and updated the installer and verification examples to 1.0.2. The signature policy threshold remains v1.0.1.
diff --git a/docs/EFFECTS.md b/docs/EFFECTS.md
index a526e7b..c32eaba 100644
--- a/docs/EFFECTS.md
+++ b/docs/EFFECTS.md
@@ -24,9 +24,21 @@
Manage installed effects:
-xfetch effects list # list installed effects
+xfetch effects list
+xfetch effects list --verbose
+xfetch effects info decrypt
xfetch effects remove decrypt
+
+ After a successful install, xfetch prints the "effects" config
+ entry, the declared capabilities and the manifest limits.
+ list --verbose adds the manifest kind, runtime, version and
+ whether the effect is enabled; info prints the details for one
+ effect. Direct wasm files and URLs accept --name and
+ --manifest; directory and repository installs use their own
+ name and manifest.
+
+
Binaries are stored in ~/.config/xfetch/effects/. Use
--repo <url> (or the XFETCH_EFFECT_REPO env var) to install
from a fork or mirror.
diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md
index b8b3a93..2d40de5 100644
--- a/docs/EXTENSIONS.md
+++ b/docs/EXTENSIONS.md
@@ -21,11 +21,23 @@
xfetch extension install /path/to/xfetch-extension-name
xfetch extension install my-extension --repo https://github.com/user/extensions.git
-List and remove installed extensions:
+List, inspect and remove installed extensions:
xfetch extension list
+xfetch extension list --verbose
+xfetch extension info <name>
xfetch extension remove <name>
+
+ After a successful install, xfetch prints the
+ "config_providers" entry, the declared capabilities and the
+ manifest limits. list --verbose adds the manifest kind, runtime,
+ version and whether the extension is enabled; info prints the
+ details for one extension. Direct wasm files and URLs accept
+ --name and --manifest; directory and repository
+ installs use their own name and manifest.
+
+
Installed binaries live in ~/.config/xfetch/extensions/ and are named
xfetch-extension-<name> (.exe on Windows).
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
index edbc07c..93e1069 100644
--- a/docs/PLUGINS.md
+++ b/docs/PLUGINS.md
@@ -48,6 +48,28 @@ XFETCH_PLUGIN_REPO=https://github.com/user/plugins.git xfetch plugin install my-
an extra development directory to that search path.
+Manage installed plugins
+
+xfetch plugin list
+xfetch plugin list --verbose
+xfetch plugin info <name>
+xfetch plugin remove <name>
+
+
+ After a successful install, xfetch prints the configuration snippet required
+ to enable the plugin, plus the declared capabilities and limits.
+ list --verbose adds the manifest kind, runtime, version,
+ capabilities and whether the plugin is currently enabled. info
+ prints the same details for one installed plugin.
+
+
+
+ Direct wasm files and URLs accept --name <name> to
+ override the installed name and --manifest <file> to
+ attach a sidecar manifest. These options do not apply to directory or
+ repository installs, which already provide their own name and manifest.
+
+
WebAssembly Plugins
diff --git a/src/addons/install.rs b/src/addons/install.rs
new file mode 100644
index 0000000..1d19ea6
--- /dev/null
+++ b/src/addons/install.rs
@@ -0,0 +1,212 @@
+//! Helpers for direct wasm installs and post-install addon identity.
+
+use crate::addons::model::AddonKind;
+use crate::install_support::validate_guest_name;
+use crate::wasm::detect::is_wasm_file;
+use crate::wasm::manifest::Manifest;
+use std::fs;
+use std::path::Path;
+
+/// Derives the installed name from a local path, URL or remote shorthand.
+///
+/// The installer uses the same conventions, so this keeps the post-install
+/// guidance and `info` commands aligned with the actual installed files.
+pub fn infer_name(
+ kind: AddonKind,
+ source: &str,
+ name_override: Option<&str>,
+) -> Result {
+ if let Some(name) = name_override {
+ validate_guest_name(name)?;
+ return Ok(name.to_string());
+ }
+
+ if is_url(source) {
+ let raw = crate::wasm::install::name_from_path(source)
+ .ok_or_else(|| format!("Cannot derive a name from URL '{}'", source))?;
+ let name = strip_prefix(kind, &raw);
+ validate_guest_name(&name)?;
+ return Ok(name);
+ }
+
+ let path = Path::new(source);
+ if path.is_dir() {
+ let name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| format!("Cannot derive a name from '{}'", source))?;
+ validate_guest_name(name)?;
+ return Ok(name.to_string());
+ }
+
+ if path.is_file() {
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| format!("Cannot derive a name from '{}'", source))?;
+ let stem = file_name.strip_suffix(".wasm").unwrap_or(file_name);
+ let name = strip_prefix(kind, stem);
+ validate_guest_name(&name)?;
+ return Ok(name);
+ }
+
+ // Remote repository paths and non-existent path-like sources install
+ // under the final path component. Apply the same suffix/prefix rules used
+ // for existing files so a dry-run path and the installed name agree.
+ let last = source.rsplit(['/', '\\']).next().unwrap_or(source);
+ let stem = last.strip_suffix(".wasm").unwrap_or(last);
+ let name = strip_prefix(kind, stem);
+ validate_guest_name(&name)?;
+ Ok(name)
+}
+
+/// Installs a direct `.wasm` file or URL. This is the path used when the user
+/// passes `--name` or `--manifest`, which the directory/repository installers
+/// do not support.
+pub fn install_direct(
+ kind: AddonKind,
+ source: &str,
+ sha256: Option<&str>,
+ name_override: Option<&str>,
+ manifest_path: Option<&str>,
+) -> Result {
+ let name = infer_name(kind, source, name_override)?;
+ let directory = kind.directory();
+ let manifest = manifest_path.map(parse_manifest).transpose()?;
+
+ if is_url(source) {
+ crate::wasm::install::download_and_install(
+ source,
+ manifest,
+ sha256,
+ &directory,
+ kind.prefix(),
+ &name,
+ kind.label(),
+ )?;
+ return Ok(name);
+ }
+
+ let path = Path::new(source);
+ if !path.is_file() || !is_wasm_file(path) {
+ return Err(format!(
+ "'{}' is not a WebAssembly file; --name/--manifest apply only to direct .wasm \
+ files or URLs",
+ source
+ ));
+ }
+
+ crate::wasm::install::install_file(
+ path,
+ sha256,
+ &directory,
+ kind.prefix(),
+ &name,
+ kind.label(),
+ )?;
+
+ if let Some(manifest) = manifest {
+ write_sidecar(kind, &name, &manifest, manifest_path)?;
+ }
+
+ Ok(name)
+}
+
+/// Parses a user-supplied sidecar manifest before it is copied into the
+/// installation directory.
+pub fn parse_manifest(path: &str) -> Result {
+ let bytes =
+ fs::read(path).map_err(|err| format!("Failed to read manifest '{}': {}", path, err))?;
+ serde_json::from_slice(&bytes).map_err(|err| format!("Invalid manifest '{}': {}", path, err))
+}
+
+fn write_sidecar(
+ kind: AddonKind,
+ name: &str,
+ manifest: &Manifest,
+ source: Option<&str>,
+) -> Result<(), String> {
+ let directory = kind.directory();
+ fs::create_dir_all(&directory)
+ .map_err(|err| format!("Failed to create {} directory: {}", kind.label(), err))?;
+ let destination = directory.join(kind.manifest_name(name));
+ let bytes = serde_json::to_vec_pretty(manifest)
+ .map_err(|err| format!("Failed to serialize manifest: {}", err))?;
+ crate::atomic_fs::write_atomic(&destination, &bytes).map_err(|err| {
+ format!(
+ "Failed to write manifest '{}': {}",
+ destination.display(),
+ err
+ )
+ })?;
+ if let Some(source) = source {
+ println!("Installed manifest '{}'", source);
+ }
+ Ok(())
+}
+
+fn strip_prefix(kind: AddonKind, value: &str) -> String {
+ let stripped = value.strip_prefix(kind.prefix()).unwrap_or(value);
+ stripped
+ .strip_suffix(".wasm")
+ .unwrap_or(stripped)
+ .to_string()
+}
+
+fn is_url(value: &str) -> bool {
+ value.starts_with("http://") || value.starts_with("https://")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn infers_plugin_name_from_wasm_file() {
+ assert_eq!(
+ infer_name(AddonKind::Plugin, "/tmp/xfetch-plugin-weather.wasm", None).expect("name"),
+ "weather"
+ );
+ }
+
+ #[test]
+ fn infers_effect_name_from_directory() {
+ assert_eq!(
+ infer_name(AddonKind::Effect, "/tmp/effects/wasm-matrix", None).expect("name"),
+ "wasm-matrix"
+ );
+ }
+
+ #[test]
+ fn infers_name_from_url() {
+ assert_eq!(
+ infer_name(
+ AddonKind::Extension,
+ "https://example.com/xfetch-extension-night-mode.wasm",
+ None
+ )
+ .expect("name"),
+ "night-mode"
+ );
+ }
+
+ #[test]
+ fn accepts_remote_shorthand() {
+ assert_eq!(
+ infer_name(AddonKind::Plugin, "weather", None).expect("name"),
+ "weather"
+ );
+ assert_eq!(
+ infer_name(AddonKind::Plugin, "plugins/weather", None).expect("name"),
+ "weather"
+ );
+ }
+
+ #[test]
+ fn override_wins() {
+ assert_eq!(
+ infer_name(AddonKind::Plugin, "/tmp/plugin.wasm", Some("custom")).expect("name"),
+ "custom"
+ );
+ }
+}
diff --git a/src/addons/mod.rs b/src/addons/mod.rs
new file mode 100644
index 0000000..a21ffcf
--- /dev/null
+++ b/src/addons/mod.rs
@@ -0,0 +1,10 @@
+//! Shared addon metadata and user-facing guidance.
+//!
+//! Plugins, effects and extensions use different directories and binary
+//! prefixes, but they share the same wasm manifest format and the same
+//! install/list/info workflow. This module keeps the cross-cutting pieces in
+//! one place so the three command families stay consistent.
+
+pub mod install;
+pub mod model;
+pub mod report;
diff --git a/src/addons/model.rs b/src/addons/model.rs
new file mode 100644
index 0000000..0b3c8b3
--- /dev/null
+++ b/src/addons/model.rs
@@ -0,0 +1,408 @@
+//! Addon identity, installed metadata and configuration snippets.
+
+use crate::config::{Config, EffectConfigList, ModuleConfig};
+use crate::effects::{
+ default_effect_dir, effect_binary_name, effect_manifest_name, effect_wasm_name,
+};
+use crate::extensions::{
+ default_extension_dir, extension_binary_name, extension_manifest_name, extension_wasm_name,
+};
+use crate::plugins::{
+ default_plugin_dir, plugin_binary_name, plugin_manifest_name, plugin_wasm_name,
+};
+use crate::wasm::detect::is_wasm_file;
+use crate::wasm::manifest::{self, Manifest, ManifestSource};
+use std::path::{Path, PathBuf};
+
+/// The three installable addon families supported by the core.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AddonKind {
+ Plugin,
+ Effect,
+ Extension,
+}
+
+impl AddonKind {
+ /// Singular label used in messages.
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::Plugin => "plugin",
+ Self::Effect => "effect",
+ Self::Extension => "extension",
+ }
+ }
+
+ /// Plural label used in list headers.
+ pub const fn plural_label(self) -> &'static str {
+ match self {
+ Self::Plugin => "plugins",
+ Self::Effect => "effects",
+ Self::Extension => "extensions",
+ }
+ }
+
+ /// Installed binary prefix (`xfetch-plugin-`, ...).
+ pub const fn prefix(self) -> &'static str {
+ match self {
+ Self::Plugin => "xfetch-plugin-",
+ Self::Effect => "xfetch-effect-",
+ Self::Extension => "xfetch-extension-",
+ }
+ }
+
+ /// Default installation directory.
+ pub fn directory(self) -> PathBuf {
+ match self {
+ Self::Plugin => default_plugin_dir(),
+ Self::Effect => default_effect_dir(),
+ Self::Extension => default_extension_dir(),
+ }
+ }
+
+ /// Native executable name for `name`.
+ pub fn binary_name(self, name: &str) -> String {
+ match self {
+ Self::Plugin => plugin_binary_name(name),
+ Self::Effect => effect_binary_name(name),
+ Self::Extension => extension_binary_name(name),
+ }
+ }
+
+ /// Wasm artifact name for `name`.
+ pub fn wasm_name(self, name: &str) -> String {
+ match self {
+ Self::Plugin => plugin_wasm_name(name),
+ Self::Effect => effect_wasm_name(name),
+ Self::Extension => extension_wasm_name(name),
+ }
+ }
+
+ /// Sidecar manifest name for `name`.
+ pub fn manifest_name(self, name: &str) -> String {
+ match self {
+ Self::Plugin => plugin_manifest_name(name),
+ Self::Effect => effect_manifest_name(name),
+ Self::Extension => extension_manifest_name(name),
+ }
+ }
+
+ /// Finds the installed native or wasm artifact in the default directory.
+ pub fn installed_path(self, name: &str) -> Option {
+ let directory = self.directory();
+ let binary = directory.join(self.binary_name(name));
+ if binary.is_file() {
+ return Some(binary);
+ }
+ let wasm = directory.join(self.wasm_name(name));
+ wasm.is_file().then_some(wasm)
+ }
+}
+
+/// How an installed addon runs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AddonRuntime {
+ Native,
+ Wasm,
+}
+
+impl AddonRuntime {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Native => "native",
+ Self::Wasm => "wasm",
+ }
+ }
+}
+
+/// Installed addon metadata assembled from the artifact and its manifest.
+#[derive(Debug, Clone)]
+pub struct AddonEntry {
+ pub kind: AddonKind,
+ pub name: String,
+ pub path: PathBuf,
+ pub runtime: AddonRuntime,
+ pub manifest: Option,
+ pub manifest_source: Option,
+ pub enabled: bool,
+}
+
+impl AddonEntry {
+ /// Loads an installed addon. Missing manifests are valid: the runtime then
+ /// applies the deny-by-default defaults.
+ pub fn load(kind: AddonKind, name: &str, path: &Path, config: &Config) -> Self {
+ let runtime = if is_wasm_file(path) {
+ AddonRuntime::Wasm
+ } else {
+ AddonRuntime::Native
+ };
+ // Native addons have no embedded manifest; avoid reading large
+ // binaries just to look for one.
+ let loaded = if runtime == AddonRuntime::Wasm {
+ manifest::load(path).ok()
+ } else {
+ None
+ };
+ let manifest = loaded.as_ref().and_then(|loaded| match loaded.source {
+ ManifestSource::Default => None,
+ _ => Some(loaded.manifest.clone()),
+ });
+ let manifest_source = loaded.map(|loaded| loaded.source.as_str().to_string());
+ Self {
+ kind,
+ name: name.to_string(),
+ path: path.to_path_buf(),
+ runtime,
+ manifest,
+ manifest_source,
+ enabled: is_enabled(kind, name, config),
+ }
+ }
+
+ /// Manifest kind, falling back to the addon family.
+ pub fn manifest_kind(&self) -> &str {
+ self.manifest
+ .as_ref()
+ .and_then(|manifest| manifest.kind.as_deref())
+ .unwrap_or_else(|| fallback_kind(self.kind))
+ }
+
+ pub fn version(&self) -> Option<&str> {
+ self.manifest
+ .as_ref()
+ .and_then(|manifest| manifest.version.as_deref())
+ }
+
+ pub fn description(&self) -> Option<&str> {
+ self.manifest
+ .as_ref()
+ .and_then(|manifest| manifest.description.as_deref())
+ }
+
+ /// Human-readable capability summary. Native addons report the process
+ /// boundary; wasm addons report the manifest allowlists.
+ pub fn capability_lines(&self) -> Vec {
+ let Some(manifest) = &self.manifest else {
+ return match self.runtime {
+ AddonRuntime::Native => vec![
+ "Native process: runs unsandboxed with the current user's privileges."
+ .to_string(),
+ ],
+ AddonRuntime::Wasm => vec![
+ "No manifest: HTTP, exec, filesystem, environment and args are denied by default."
+ .to_string(),
+ ],
+ };
+ };
+
+ let capabilities = &manifest.capabilities;
+ let mut lines = Vec::new();
+ lines.push(format!(
+ "HTTP allow: {}",
+ join_or_none(&capabilities.http.allow)
+ ));
+ lines.push(format!(
+ "Exec allow: {}",
+ join_or_none(&capabilities.exec.allow)
+ ));
+ lines.push(format!(
+ "Filesystem: {}",
+ filesystem_summary(&capabilities.fs)
+ ));
+ lines.push(format!("Environment: {}", join_or_none(&capabilities.env)));
+ lines.push(format!(
+ "Args: {}",
+ if capabilities.args {
+ "allowed"
+ } else {
+ "denied"
+ }
+ ));
+ lines
+ }
+
+ /// Runtime limits in a single line.
+ pub fn limits_line(&self) -> Option {
+ let limits = self.manifest.as_ref()?.limits.clone();
+ Some(format!(
+ "timeout {} ms, memory {} MiB, output {} KiB, host calls {} KiB",
+ limits.timeout_ms, limits.memory_mb, limits.output_kb, limits.host_call_kb
+ ))
+ }
+
+ /// JSONC snippet that enables the addon in the user's config.
+ pub fn config_snippet(&self) -> Vec {
+ config_snippet(self.kind, &self.name, Some(self.manifest_kind()))
+ }
+}
+
+/// Snippet for an addon whose manifest kind may be unknown (for example a
+/// native plugin installed without a sidecar).
+pub fn config_snippet(kind: AddonKind, name: &str, manifest_kind: Option<&str>) -> Vec {
+ match kind {
+ AddonKind::Plugin => plugin_snippet(name, manifest_kind),
+ AddonKind::Effect => vec![
+ "\"effects\": [".to_string(),
+ format!(" {{ \"plugin\": \"{name}\" }}"),
+ "]".to_string(),
+ ],
+ AddonKind::Extension => vec![
+ "\"config_providers\": [".to_string(),
+ format!(" {{ \"extension\": \"{name}\" }}"),
+ "]".to_string(),
+ ],
+ }
+}
+
+fn plugin_snippet(name: &str, manifest_kind: Option<&str>) -> Vec {
+ let info = vec![
+ "\"info_plugins\": [".to_string(),
+ format!(" {{ \"plugin\": \"{name}\" }}"),
+ "],".to_string(),
+ "\"modules\": [".to_string(),
+ " ...,".to_string(),
+ format!(" \"plugin:{name}\""),
+ "]".to_string(),
+ ];
+ let logo = vec![
+ "\"logo_animation\": {".to_string(),
+ format!(" \"plugin\": \"{name}\""),
+ "}".to_string(),
+ ];
+
+ match manifest_kind {
+ Some("logo_animation") => logo,
+ Some("info_provider") => info,
+ _ => {
+ let mut lines = vec!["Info provider:".to_string()];
+ lines.extend(info.into_iter().map(|line| format!(" {line}")));
+ lines.push(String::new());
+ lines.push("Logo animation:".to_string());
+ lines.extend(logo.into_iter().map(|line| format!(" {line}")));
+ lines
+ }
+ }
+}
+
+fn fallback_kind(kind: AddonKind) -> &'static str {
+ match kind {
+ AddonKind::Plugin => "plugin",
+ AddonKind::Effect => "effect",
+ AddonKind::Extension => "config_provider",
+ }
+}
+
+fn join_or_none(values: &[String]) -> String {
+ if values.is_empty() {
+ "none".to_string()
+ } else {
+ values.join(", ")
+ }
+}
+
+fn filesystem_summary(entries: &[crate::wasm::manifest::FsEntry]) -> String {
+ use crate::wasm::manifest::FsEntry;
+
+ if entries.is_empty() {
+ return "none".to_string();
+ }
+
+ entries
+ .iter()
+ .map(|entry| match entry {
+ FsEntry::Path(path) => format!("{path} (read-only)"),
+ FsEntry::Mount { host, guest, mode } => {
+ format!("{host} -> {guest} ({mode:?})")
+ }
+ })
+ .collect::>()
+ .join(", ")
+}
+
+fn is_enabled(kind: AddonKind, name: &str, config: &Config) -> bool {
+ match kind {
+ AddonKind::Plugin => {
+ let info_configured = config
+ .info_plugins
+ .iter()
+ .any(|plugin| plugin.plugin == name);
+ let info_rendered = modules_include_plugin(&config.modules, name);
+ let logo_configured = config
+ .logo_animation
+ .as_ref()
+ .and_then(|animation| animation.plugin.as_deref())
+ == Some(name);
+ (info_configured && info_rendered) || logo_configured
+ }
+ AddonKind::Effect => match &config.effects {
+ Some(EffectConfigList::Single(effect)) => effect.plugin.as_deref() == Some(name),
+ Some(EffectConfigList::Multiple(effects)) => effects
+ .iter()
+ .any(|effect| effect.plugin.as_deref() == Some(name)),
+ None => false,
+ },
+ AddonKind::Extension => config
+ .config_providers
+ .iter()
+ .any(|provider| provider.extension == name),
+ }
+}
+
+fn modules_include_plugin(modules: &[ModuleConfig], name: &str) -> bool {
+ let key = format!("plugin:{name}");
+ modules.iter().any(|module| match module {
+ ModuleConfig::Simple(value) => value == &key,
+ ModuleConfig::Group { modules, .. } => modules_include_plugin(modules, name),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn config_with_modules(modules: Vec) -> Config {
+ Config {
+ modules,
+ ..Config::default()
+ }
+ }
+
+ #[test]
+ fn info_snippet_includes_module_key() {
+ let snippet = config_snippet(AddonKind::Plugin, "weather", Some("info_provider"));
+ let text = snippet.join("\n");
+ assert!(text.contains("\"info_plugins\""));
+ assert!(text.contains("\"plugin:weather\""));
+ }
+
+ #[test]
+ fn logo_snippet_uses_logo_animation() {
+ let snippet = config_snippet(AddonKind::Plugin, "animate-logo", Some("logo_animation"));
+ let text = snippet.join("\n");
+ assert!(text.contains("\"logo_animation\""));
+ assert!(!text.contains("\"info_plugins\""));
+ }
+
+ #[test]
+ fn effect_and_extension_snippets_use_their_keys() {
+ let effect = config_snippet(AddonKind::Effect, "matrix", None).join("\n");
+ let extension = config_snippet(AddonKind::Extension, "night", None).join("\n");
+ assert!(effect.contains("\"effects\""));
+ assert!(extension.contains("\"config_providers\""));
+ }
+
+ #[test]
+ fn info_plugin_enabled_only_when_module_is_present() {
+ let mut config = config_with_modules(vec![ModuleConfig::Simple("os".to_string())]);
+ config.info_plugins.push(crate::config::InfoPluginConfig {
+ plugin: "weather".to_string(),
+ args: None,
+ timeout_secs: None,
+ });
+ assert!(!is_enabled(AddonKind::Plugin, "weather", &config));
+
+ config
+ .modules
+ .push(ModuleConfig::Simple("plugin:weather".to_string()));
+ assert!(is_enabled(AddonKind::Plugin, "weather", &config));
+ }
+}
diff --git a/src/addons/report.rs b/src/addons/report.rs
new file mode 100644
index 0000000..be7a373
--- /dev/null
+++ b/src/addons/report.rs
@@ -0,0 +1,116 @@
+//! Human-readable output for addon commands.
+
+use crate::addons::model::{AddonEntry, AddonKind};
+use crate::config::Config;
+
+/// Prints the installed addon list. The default layout is unchanged for
+/// scripts; `verbose` adds kind, runtime, version, capabilities and state.
+pub fn print_list(kind: AddonKind, entries: &[AddonEntry], verbose: bool) {
+ if entries.is_empty() {
+ println!("No {} installed.", kind.plural_label());
+ println!("{} directory: {}", title(kind), kind.directory().display());
+ return;
+ }
+
+ if !verbose {
+ println!("Installed {}:", kind.plural_label());
+ for entry in entries {
+ println!(" {} ({})", entry.name, entry.path.display());
+ }
+ return;
+ }
+
+ println!("Installed {}:", kind.plural_label());
+ for entry in entries {
+ let version = entry.version().unwrap_or("-");
+ let state = if entry.enabled {
+ "enabled"
+ } else {
+ "not enabled"
+ };
+ println!(
+ " {} ({}, {}, v{}, {})",
+ entry.name,
+ entry.manifest_kind(),
+ entry.runtime.as_str(),
+ version,
+ state
+ );
+ println!(" path: {}", entry.path.display());
+ if let Some(description) = entry.description() {
+ println!(" description: {}", description);
+ }
+ for line in entry.capability_lines() {
+ println!(" {}", line);
+ }
+ if let Some(limits) = entry.limits_line() {
+ println!(" limits: {}", limits);
+ }
+ }
+}
+
+/// Prints one installed addon with its manifest, capabilities and the config
+/// snippet required to enable it.
+pub fn print_info(entry: &AddonEntry) {
+ println!("Name: {}", entry.name);
+ println!("Type: {} ({})", entry.kind.label(), entry.manifest_kind());
+ println!("Runtime: {}", entry.runtime.as_str());
+ if let Some(version) = entry.version() {
+ println!("Version: {}", version);
+ }
+ if let Some(description) = entry.description() {
+ println!("Description: {}", description);
+ }
+ println!("Path: {}", entry.path.display());
+ if let Some(source) = &entry.manifest_source {
+ println!("Manifest: {}", source);
+ }
+ println!("Enabled: {}", if entry.enabled { "yes" } else { "no" });
+
+ println!("Capabilities:");
+ for line in entry.capability_lines() {
+ println!(" {}", line);
+ }
+ if let Some(limits) = entry.limits_line() {
+ println!("Limits: {}", limits);
+ }
+
+ println!("Configuration:");
+ for line in entry.config_snippet() {
+ println!(" {}", line);
+ }
+}
+
+/// Prints the config snippet and declared capabilities after a successful
+/// install. The installer has already printed the artifact path.
+pub fn print_install_guidance(kind: AddonKind, name: &str) {
+ let path = kind
+ .installed_path(name)
+ .unwrap_or_else(|| kind.directory().join(kind.wasm_name(name)));
+ let entry = AddonEntry::load(kind, name, &path, &Config::default());
+
+ println!();
+ println!("To enable '{}':", name);
+ for line in entry.config_snippet() {
+ println!("{}", line);
+ }
+ if let Some(limits) = entry.limits_line() {
+ println!();
+ println!("Limits: {}", limits);
+ }
+ let capabilities = entry.capability_lines();
+ if !capabilities.is_empty() {
+ println!("Capabilities:");
+ for line in capabilities {
+ println!(" {}", line);
+ }
+ }
+}
+
+fn title(kind: AddonKind) -> &'static str {
+ match kind {
+ AddonKind::Plugin => "Plugin",
+ AddonKind::Effect => "Effect",
+ AddonKind::Extension => "Extension",
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 611f78e..39febbd 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,7 @@ use clap::{Parser, Subcommand};
version,
about,
long_about = None,
- after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme"
+ after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme"
)]
pub struct Cli {
#[command(subcommand)]
@@ -58,10 +58,12 @@ pub struct Cli {
#[derive(Subcommand, Debug)]
pub enum Commands {
+ #[command(alias = "plugins")]
Plugin {
#[command(subcommand)]
action: PluginCommands,
},
+ #[command(alias = "extensions")]
Extension {
#[command(subcommand)]
action: ExtensionCommands,
@@ -70,6 +72,7 @@ pub enum Commands {
#[command(subcommand)]
action: ThemeCommands,
},
+ #[command(alias = "effect")]
Effects {
#[command(subcommand)]
action: EffectCommands,
@@ -107,8 +110,21 @@ pub enum PluginCommands {
/// Expected SHA-256 (hex) of a downloaded wasm artifact.
#[arg(long, value_name = "HEX")]
sha256: Option,
+ /// Override the installed name for a direct wasm file or URL.
+ #[arg(long)]
+ name: Option,
+ /// Sidecar manifest to attach to a direct wasm file or URL.
+ #[arg(long, value_name = "FILE")]
+ manifest: Option,
+ },
+ List {
+ /// Show kind, runtime, version, capabilities and enabled state.
+ #[arg(long, short)]
+ verbose: bool,
+ },
+ Info {
+ name: String,
},
- List,
Remove {
name: String,
},
@@ -123,8 +139,21 @@ pub enum ExtensionCommands {
/// Expected SHA-256 (hex) of a downloaded wasm artifact.
#[arg(long, value_name = "HEX")]
sha256: Option,
+ /// Override the installed name for a direct wasm file or URL.
+ #[arg(long)]
+ name: Option,
+ /// Sidecar manifest to attach to a direct wasm file or URL.
+ #[arg(long, value_name = "FILE")]
+ manifest: Option,
+ },
+ List {
+ /// Show kind, runtime, version, capabilities and enabled state.
+ #[arg(long, short)]
+ verbose: bool,
+ },
+ Info {
+ name: String,
},
- List,
Remove {
name: String,
},
@@ -147,8 +176,21 @@ pub enum EffectCommands {
/// Expected SHA-256 (hex) of a downloaded wasm artifact.
#[arg(long, value_name = "HEX")]
sha256: Option,
+ /// Override the installed name for a direct wasm file or URL.
+ #[arg(long)]
+ name: Option,
+ /// Sidecar manifest to attach to a direct wasm file or URL.
+ #[arg(long, value_name = "FILE")]
+ manifest: Option,
+ },
+ List {
+ /// Show kind, runtime, version, capabilities and enabled state.
+ #[arg(long, short)]
+ verbose: bool,
+ },
+ Info {
+ name: String,
},
- List,
Remove {
name: String,
},
diff --git a/src/main.rs b/src/main.rs
index 6eddaa7..87c20e9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
+mod addons;
mod atomic_fs;
mod cache;
mod cli;
@@ -17,6 +18,8 @@ mod ui;
mod update;
mod wasm;
+use crate::addons::install::infer_name;
+use crate::addons::model::{AddonEntry, AddonKind};
use crate::config::{generate_config, load_config};
use crate::effects::{install_effect, list_effects, remove_effect};
use crate::extensions::{install_extension, list_extensions, remove_extension};
@@ -75,59 +78,124 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> {
match cli.command {
Some(Commands::Plugin { action }) => match action {
- PluginCommands::Install { path, repo, sha256 } => {
- install_plugin(&path, repo.as_deref(), sha256.as_deref())
- .map_err(error::XFetchError::Fatal)
+ PluginCommands::Install {
+ path,
+ repo,
+ sha256,
+ name,
+ manifest,
+ } => {
+ let direct = name.is_some() || manifest.is_some();
+ let installed = if direct {
+ if repo.is_some() {
+ return Err(error::XFetchError::Fatal(
+ "--name/--manifest apply to direct wasm files or URLs; omit --repo"
+ .to_string(),
+ ));
+ }
+ addons::install::install_direct(
+ AddonKind::Plugin,
+ &path,
+ sha256.as_deref(),
+ name.as_deref(),
+ manifest.as_deref(),
+ )
+ .map_err(error::XFetchError::Fatal)?
+ } else {
+ install_plugin(&path, repo.as_deref(), sha256.as_deref())
+ .map_err(error::XFetchError::Fatal)?;
+ infer_name(AddonKind::Plugin, &path, None).map_err(error::XFetchError::Fatal)?
+ };
+ addons::report::print_install_guidance(AddonKind::Plugin, &installed);
+ Ok(())
}
- PluginCommands::List => {
- list_plugins()
- .map_err(error::XFetchError::Fatal)
- .map(|plugins| {
- if plugins.is_empty() {
- println!("No plugins installed.");
- println!(
- "Plugin directory: {}",
- plugins::default_plugin_dir().display()
- );
- } else {
- println!("Installed plugins:");
- for (name, path) in &plugins {
- println!(" {} ({})", name, path.display());
- }
- }
- })
+ PluginCommands::List { verbose } => {
+ let installed = list_plugins().map_err(error::XFetchError::Fatal)?;
+ let config = load_config(cli.config.clone());
+ let entries = installed
+ .into_iter()
+ .map(|(name, path)| AddonEntry::load(AddonKind::Plugin, &name, &path, &config))
+ .collect::>();
+ addons::report::print_list(AddonKind::Plugin, &entries, verbose);
+ Ok(())
+ }
+ PluginCommands::Info { name } => {
+ let path = AddonKind::Plugin
+ .installed_path(&name)
+ .or_else(|| plugins::find_plugin_binary(&name))
+ .ok_or_else(|| {
+ error::XFetchError::Fatal(format!("Plugin '{}' is not installed.", name))
+ })?;
+ let config = load_config(cli.config.clone());
+ let entry = AddonEntry::load(AddonKind::Plugin, &name, &path, &config);
+ addons::report::print_info(&entry);
+ Ok(())
}
PluginCommands::Remove { name } => {
remove_plugin(&name).map_err(error::XFetchError::Fatal)
}
},
- Some(Commands::Extension { action }) => {
- match action {
- ExtensionCommands::Install { path, repo, sha256 } => {
+ Some(Commands::Extension { action }) => match action {
+ ExtensionCommands::Install {
+ path,
+ repo,
+ sha256,
+ name,
+ manifest,
+ } => {
+ let direct = name.is_some() || manifest.is_some();
+ let installed = if direct {
+ if repo.is_some() {
+ return Err(error::XFetchError::Fatal(
+ "--name/--manifest apply to direct wasm files or URLs; omit --repo"
+ .to_string(),
+ ));
+ }
+ addons::install::install_direct(
+ AddonKind::Extension,
+ &path,
+ sha256.as_deref(),
+ name.as_deref(),
+ manifest.as_deref(),
+ )
+ .map_err(error::XFetchError::Fatal)?
+ } else {
install_extension(&path, repo.as_deref(), sha256.as_deref())
- .map_err(error::XFetchError::Fatal)
- }
- ExtensionCommands::List => list_extensions()
- .map_err(error::XFetchError::Fatal)
- .map(|extensions| {
- if extensions.is_empty() {
- println!("No extensions installed.");
- println!(
- "Extension directory: {}",
- extensions::default_extension_dir().display()
- );
- } else {
- println!("Installed extensions:");
- for (name, path) in &extensions {
- println!(" {} ({})", name, path.display());
- }
- }
- }),
- ExtensionCommands::Remove { name } => {
- remove_extension(&name).map_err(error::XFetchError::Fatal)
- }
+ .map_err(error::XFetchError::Fatal)?;
+ infer_name(AddonKind::Extension, &path, None)
+ .map_err(error::XFetchError::Fatal)?
+ };
+ addons::report::print_install_guidance(AddonKind::Extension, &installed);
+ Ok(())
}
- }
+ ExtensionCommands::List { verbose } => {
+ let installed = list_extensions().map_err(error::XFetchError::Fatal)?;
+ let config = load_config(cli.config.clone());
+ let entries = installed
+ .into_iter()
+ .map(|(name, path)| {
+ AddonEntry::load(AddonKind::Extension, &name, &path, &config)
+ })
+ .collect::>();
+ addons::report::print_list(AddonKind::Extension, &entries, verbose);
+ Ok(())
+ }
+ ExtensionCommands::Info { name } => {
+ let path = AddonKind::Extension
+ .installed_path(&name)
+ .or_else(|| extensions::find_extension_binary(&name))
+ .ok_or_else(|| {
+ error::XFetchError::Fatal(format!("Extension '{}' is not installed.", name))
+ })?;
+ let config = load_config(cli.config.clone());
+ let entry = AddonEntry::load(AddonKind::Extension, &name, &path, &config);
+ addons::report::print_info(&entry);
+ Ok(())
+ }
+ ExtensionCommands::Remove { name } => {
+ remove_extension(&name).map_err(error::XFetchError::Fatal)
+ }
+ },
Some(Commands::Theme { action }) => match action {
ThemeCommands::List => list_themes()
.map_err(error::XFetchError::Fatal)
@@ -259,27 +327,58 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> {
}
},
Some(Commands::Effects { action }) => match action {
- EffectCommands::Install { path, repo, sha256 } => {
- install_effect(&path, repo.as_deref(), sha256.as_deref())
- .map_err(error::XFetchError::Fatal)
+ EffectCommands::Install {
+ path,
+ repo,
+ sha256,
+ name,
+ manifest,
+ } => {
+ let direct = name.is_some() || manifest.is_some();
+ let installed = if direct {
+ if repo.is_some() {
+ return Err(error::XFetchError::Fatal(
+ "--name/--manifest apply to direct wasm files or URLs; omit --repo"
+ .to_string(),
+ ));
+ }
+ addons::install::install_direct(
+ AddonKind::Effect,
+ &path,
+ sha256.as_deref(),
+ name.as_deref(),
+ manifest.as_deref(),
+ )
+ .map_err(error::XFetchError::Fatal)?
+ } else {
+ install_effect(&path, repo.as_deref(), sha256.as_deref())
+ .map_err(error::XFetchError::Fatal)?;
+ infer_name(AddonKind::Effect, &path, None).map_err(error::XFetchError::Fatal)?
+ };
+ addons::report::print_install_guidance(AddonKind::Effect, &installed);
+ Ok(())
}
- EffectCommands::List => {
- list_effects()
- .map_err(error::XFetchError::Fatal)
- .map(|effects| {
- if effects.is_empty() {
- println!("No effects installed.");
- println!(
- "Effect directory: {}",
- effects::default_effect_dir().display()
- );
- } else {
- println!("Installed effects:");
- for (name, path) in &effects {
- println!(" {} ({})", name, path.display());
- }
- }
- })
+ EffectCommands::List { verbose } => {
+ let installed = list_effects().map_err(error::XFetchError::Fatal)?;
+ let config = load_config(cli.config.clone());
+ let entries = installed
+ .into_iter()
+ .map(|(name, path)| AddonEntry::load(AddonKind::Effect, &name, &path, &config))
+ .collect::>();
+ addons::report::print_list(AddonKind::Effect, &entries, verbose);
+ Ok(())
+ }
+ EffectCommands::Info { name } => {
+ let path = AddonKind::Effect
+ .installed_path(&name)
+ .or_else(|| effects::find_effect_binary(&name))
+ .ok_or_else(|| {
+ error::XFetchError::Fatal(format!("Effect '{}' is not installed.", name))
+ })?;
+ let config = load_config(cli.config.clone());
+ let entry = AddonEntry::load(AddonKind::Effect, &name, &path, &config);
+ addons::report::print_info(&entry);
+ Ok(())
}
EffectCommands::Remove { name } => {
remove_effect(&name).map_err(error::XFetchError::Fatal)
From 4f82bcbb09f250ea5a1c2a8ec110d4ed438b5467 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 14:30:46 +0100
Subject: [PATCH 06/11] feat(addons): add catalog search
- Add a catalog module that reads the plugin, effect and extension
index.json files and filters entries by name, description, kind or runtime.
- Add search/available commands for the three addon families, with optional
--json output and local/remote catalog overrides.
- Document the new commands and record them in the v1.0.2 changelog.
---
CHANGELOG.md | 1 +
docs/EFFECTS.md | 9 ++
docs/EXTENSIONS.md | 9 ++
docs/PLUGINS.md | 10 ++
src/addons/catalog.rs | 241 ++++++++++++++++++++++++++++++++++++++++++
src/addons/mod.rs | 1 +
src/cli.rs | 26 ++++-
src/main.rs | 12 +++
8 files changed, 308 insertions(+), 1 deletion(-)
create mode 100644 src/addons/catalog.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa87d60..5310b20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@
- `xfetch plugin`, `xfetch effects` and `xfetch extension` installs now print the configuration snippet required to enable the installed addon, along with its declared capabilities and limits.
- Added `list --verbose` and `info ` for plugins, effects and extensions. They report the manifest kind, runtime, version, capabilities, limits and whether the addon is enabled.
- Direct wasm files and URLs now accept `--name` and `--manifest` on install; directory and repository installs keep using their own name and manifest.
+- Added `search` (alias `available`) for the remote plugin, effect and extension catalogs, with optional `--json` output and `XFETCH_PLUGIN_INDEX`/`XFETCH_EFFECT_INDEX`/`XFETCH_EXTENSION_INDEX` overrides.
- Added command aliases `plugins`, `effect`, and `extensions` for the existing command families.
### Release
diff --git a/docs/EFFECTS.md b/docs/EFFECTS.md
index c32eaba..443150a 100644
--- a/docs/EFFECTS.md
+++ b/docs/EFFECTS.md
@@ -27,8 +27,17 @@
xfetch effects list
xfetch effects list --verbose
xfetch effects info decrypt
+xfetch effects search <query>
+xfetch effects available
xfetch effects remove decrypt
+
+ search reads the effects catalog (index.json),
+ filters by name, description or runtime, and prints the matching install
+ command. available lists the whole catalog, --json
+ emits JSON, and XFETCH_EFFECT_INDEX overrides the catalog URL.
+
+
After a successful install, xfetch prints the "effects" config
entry, the declared capabilities and the manifest limits.
diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md
index 2d40de5..604a791 100644
--- a/docs/EXTENSIONS.md
+++ b/docs/EXTENSIONS.md
@@ -26,8 +26,17 @@ xfetch extension install my-extension --repo https://github.com/user/extensions.
xfetch extension list
xfetch extension list --verbose
xfetch extension info <name>
+xfetch extension search <query>
+xfetch extension available
xfetch extension remove <name>
+
+ search reads the extensions catalog (index.json),
+ filters by name, description or runtime, and prints the matching install
+ command. available lists the whole catalog, --json
+ emits JSON, and XFETCH_EXTENSION_INDEX overrides the catalog URL.
+
+
After a successful install, xfetch prints the
"config_providers" entry, the declared capabilities and the
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
index 93e1069..0110aa8 100644
--- a/docs/PLUGINS.md
+++ b/docs/PLUGINS.md
@@ -53,8 +53,18 @@ XFETCH_PLUGIN_REPO=https://github.com/user/plugins.git xfetch plugin install my-
xfetch plugin list
xfetch plugin list --verbose
xfetch plugin info <name>
+xfetch plugin search <query>
+xfetch plugin available
xfetch plugin remove <name>
+
+ search reads the repository catalog (index.json),
+ filters by name, description, kind or runtime, and prints the matching
+ install command. available is an alias with an empty query, and
+ --json emits machine-readable results. Set
+ XFETCH_PLUGIN_INDEX to use a local file or a fork's catalog.
+
+
After a successful install, xfetch prints the configuration snippet required
to enable the plugin, plus the declared capabilities and limits.
diff --git a/src/addons/catalog.rs b/src/addons/catalog.rs
new file mode 100644
index 0000000..b99ddaf
--- /dev/null
+++ b/src/addons/catalog.rs
@@ -0,0 +1,241 @@
+//! Remote addon catalogs used by `search` and `available`.
+//!
+//! Each addon repository publishes an `index.json` at its root. The catalog is
+//! intentionally small: it only carries the metadata needed to discover an
+//! addon before installing it. Installation still uses the repository and
+//! manifest, so the catalog cannot grant capabilities by itself.
+
+use crate::addons::model::AddonKind;
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::time::Duration;
+
+/// Maximum catalog size accepted from a remote source.
+const CATALOG_LIMIT: u64 = 2 * 1024 * 1024;
+
+/// Default raw catalog URL for each repository.
+const PLUGIN_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/plugins/main/index.json";
+const EFFECT_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/effects/main/index.json";
+const EXTENSION_CATALOG: &str =
+ "https://raw.githubusercontent.com/xfetch-cli/extensions/main/index.json";
+
+/// One addon advertised by a repository catalog.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CatalogEntry {
+ pub name: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub version: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub kind: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub runtime: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub path: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub install: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub guest_runtime: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub capabilities: Option,
+}
+
+impl CatalogEntry {
+ fn install_name(&self) -> &str {
+ self.install.as_deref().unwrap_or(&self.name)
+ }
+
+ fn matches(&self, query: &str) -> bool {
+ if query.is_empty() {
+ return true;
+ }
+ let query = query.to_ascii_lowercase();
+ let haystack = [
+ self.name.as_str(),
+ self.description.as_str(),
+ self.kind.as_deref().unwrap_or_default(),
+ self.runtime.as_deref().unwrap_or_default(),
+ ];
+ haystack
+ .iter()
+ .any(|value| value.to_ascii_lowercase().contains(&query))
+ }
+}
+
+/// A repository catalog document.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Catalog {
+ #[serde(default = "default_schema_version")]
+ pub schema_version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub catalog_version: Option,
+ #[serde(default)]
+ pub entries: Vec,
+}
+
+fn default_schema_version() -> u32 {
+ 1
+}
+
+/// Fetches the catalog for `kind` and filters it by `query`.
+pub fn search(kind: AddonKind, query: &str) -> Result, String> {
+ let catalog = load(kind)?;
+ Ok(catalog
+ .entries
+ .into_iter()
+ .filter(|entry| entry.matches(query))
+ .collect())
+}
+
+/// Prints search results as a readable list or JSON.
+pub fn print_search(kind: AddonKind, query: Option<&str>, json: bool) -> Result<(), String> {
+ let query = query.unwrap_or("");
+ let entries = search(kind, query)?;
+
+ if json {
+ let body = serde_json::to_string_pretty(&entries)
+ .map_err(|err| format!("Failed to serialize catalog results: {}", err))?;
+ println!("{}", body);
+ return Ok(());
+ }
+
+ if entries.is_empty() {
+ if query.is_empty() {
+ println!("No {} available in the catalog.", kind.plural_label());
+ } else {
+ println!("No {} matched '{}'.", kind.plural_label(), query);
+ }
+ return Ok(());
+ }
+
+ println!("Available {}:", kind.plural_label());
+ for entry in entries {
+ let version = entry.version.as_deref().unwrap_or("-");
+ let entry_kind = entry.kind.as_deref().unwrap_or(kind.label());
+ let runtime = entry.runtime.as_deref().unwrap_or("unknown");
+ println!(
+ " {} ({}, {}, v{})",
+ entry.name, entry_kind, runtime, version
+ );
+ if !entry.description.is_empty() {
+ println!(" {}", entry.description);
+ }
+ println!(
+ " install: {}",
+ install_command(kind, entry.install_name())
+ );
+ }
+ Ok(())
+}
+
+fn install_command(kind: AddonKind, name: &str) -> String {
+ match kind {
+ AddonKind::Plugin => format!("xfetch plugin install {}", name),
+ AddonKind::Effect => format!("xfetch effects install {}", name),
+ AddonKind::Extension => format!("xfetch extension install {}", name),
+ }
+}
+
+fn load(kind: AddonKind) -> Result {
+ let source = catalog_source(kind);
+ let body = if source.starts_with("http://") || source.starts_with("https://") {
+ fetch_remote(&source)?
+ } else {
+ fs::read_to_string(&source)
+ .map_err(|err| format!("Failed to read catalog '{}': {}", source, err))?
+ };
+ parse(&body).map_err(|err| format!("Invalid catalog '{}': {}", source, err))
+}
+
+fn catalog_source(kind: AddonKind) -> String {
+ let (env_name, default) = match kind {
+ AddonKind::Plugin => ("XFETCH_PLUGIN_INDEX", PLUGIN_CATALOG),
+ AddonKind::Effect => ("XFETCH_EFFECT_INDEX", EFFECT_CATALOG),
+ AddonKind::Extension => ("XFETCH_EXTENSION_INDEX", EXTENSION_CATALOG),
+ };
+ std::env::var(env_name).unwrap_or_else(|_| default.to_string())
+}
+
+fn fetch_remote(source: &str) -> Result {
+ let mut response = ureq::get(source)
+ .config()
+ .timeout_global(Some(Duration::from_secs(15)))
+ .build()
+ .call()
+ .map_err(|err| format!("Failed to fetch catalog '{}': {}", source, err))?;
+ let status = response.status().as_u16();
+ if !(200..300).contains(&status) {
+ return Err(format!(
+ "Failed to fetch catalog '{}': HTTP {}",
+ source, status
+ ));
+ }
+ response
+ .body_mut()
+ .with_config()
+ .limit(CATALOG_LIMIT)
+ .read_to_string()
+ .map_err(|err| format!("Failed to read catalog '{}': {}", source, err))
+}
+
+fn parse(body: &str) -> Result {
+ serde_json::from_str(body)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SAMPLE: &str = r#"
+ {
+ "schema_version": 1,
+ "catalog_version": "1.0.2",
+ "entries": [
+ {
+ "name": "wasm-crypto",
+ "description": "Crypto prices over HTTP",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "wasm",
+ "path": "plugins/wasm-crypto",
+ "install": "wasm-crypto"
+ },
+ {
+ "name": "weather",
+ "description": "Current weather",
+ "version": "0.2.0",
+ "kind": "info_provider",
+ "runtime": "native",
+ "path": "plugins/weather"
+ }
+ ]
+ }
+ "#;
+
+ #[test]
+ fn parses_catalog_and_filters_entries() {
+ let catalog = parse(SAMPLE).expect("catalog");
+ assert_eq!(catalog.entries.len(), 2);
+ assert!(catalog.entries[0].matches("crypto"));
+ assert!(catalog.entries[0].matches("WASM"));
+ assert!(!catalog.entries[0].matches("weather"));
+ assert_eq!(catalog.entries[1].install_name(), "weather");
+ }
+
+ #[test]
+ fn install_commands_match_the_command_family() {
+ assert_eq!(
+ install_command(AddonKind::Plugin, "weather"),
+ "xfetch plugin install weather"
+ );
+ assert_eq!(
+ install_command(AddonKind::Effect, "matrix"),
+ "xfetch effects install matrix"
+ );
+ assert_eq!(
+ install_command(AddonKind::Extension, "night"),
+ "xfetch extension install night"
+ );
+ }
+}
diff --git a/src/addons/mod.rs b/src/addons/mod.rs
index a21ffcf..f02a3ba 100644
--- a/src/addons/mod.rs
+++ b/src/addons/mod.rs
@@ -5,6 +5,7 @@
//! install/list/info workflow. This module keeps the cross-cutting pieces in
//! one place so the three command families stay consistent.
+pub mod catalog;
pub mod install;
pub mod model;
pub mod report;
diff --git a/src/cli.rs b/src/cli.rs
index 39febbd..1c42eb0 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,7 @@ use clap::{Parser, Subcommand};
version,
about,
long_about = None,
- after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme"
+ after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin search logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects available\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension search night\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme"
)]
pub struct Cli {
#[command(subcommand)]
@@ -125,6 +125,14 @@ pub enum PluginCommands {
Info {
name: String,
},
+ /// Search the remote plugin catalog. Omit the query to list everything.
+ #[command(alias = "available")]
+ Search {
+ query: Option,
+ /// Emit the matching entries as JSON.
+ #[arg(long)]
+ json: bool,
+ },
Remove {
name: String,
},
@@ -154,6 +162,14 @@ pub enum ExtensionCommands {
Info {
name: String,
},
+ /// Search the remote extension catalog. Omit the query to list everything.
+ #[command(alias = "available")]
+ Search {
+ query: Option,
+ /// Emit the matching entries as JSON.
+ #[arg(long)]
+ json: bool,
+ },
Remove {
name: String,
},
@@ -191,6 +207,14 @@ pub enum EffectCommands {
Info {
name: String,
},
+ /// Search the remote effect catalog. Omit the query to list everything.
+ #[command(alias = "available")]
+ Search {
+ query: Option,
+ /// Emit the matching entries as JSON.
+ #[arg(long)]
+ json: bool,
+ },
Remove {
name: String,
},
diff --git a/src/main.rs b/src/main.rs
index 87c20e9..1ef0009 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -131,6 +131,10 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> {
addons::report::print_info(&entry);
Ok(())
}
+ PluginCommands::Search { query, json } => {
+ addons::catalog::print_search(AddonKind::Plugin, query.as_deref(), json)
+ .map_err(error::XFetchError::Fatal)
+ }
PluginCommands::Remove { name } => {
remove_plugin(&name).map_err(error::XFetchError::Fatal)
}
@@ -192,6 +196,10 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> {
addons::report::print_info(&entry);
Ok(())
}
+ ExtensionCommands::Search { query, json } => {
+ addons::catalog::print_search(AddonKind::Extension, query.as_deref(), json)
+ .map_err(error::XFetchError::Fatal)
+ }
ExtensionCommands::Remove { name } => {
remove_extension(&name).map_err(error::XFetchError::Fatal)
}
@@ -380,6 +388,10 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> {
addons::report::print_info(&entry);
Ok(())
}
+ EffectCommands::Search { query, json } => {
+ addons::catalog::print_search(AddonKind::Effect, query.as_deref(), json)
+ .map_err(error::XFetchError::Fatal)
+ }
EffectCommands::Remove { name } => {
remove_effect(&name).map_err(error::XFetchError::Fatal)
}
From b4c7efe9b4af76231e64d8c32e4493b36a6d12b6 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 14:34:50 +0100
Subject: [PATCH 07/11] feat(catalog): validate schema version and document
search
---
README.md | 4 ++++
src/addons/catalog.rs | 9 ++++++++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f08e6f1..4a19391 100644
--- a/README.md
+++ b/README.md
@@ -197,9 +197,13 @@ xfetch --config path/to/config.jsonc
xfetch --gen-config # generate a starter config
xfetch --clean-cache # clear the module cache
xfetch plugin install # install a plugin
+xfetch plugin search # search the plugin catalog
+xfetch plugin available # list the plugin catalog
xfetch extension install # install an extension
+xfetch extension search # search the extension catalog
xfetch theme list # list themes
xfetch effects install # install an intro effect (e.g. decrypt)
+xfetch effects search # search the effect catalog
xfetch update --check # check for a newer release
xfetch update # update to the latest release
```
diff --git a/src/addons/catalog.rs b/src/addons/catalog.rs
index b99ddaf..513a2a6 100644
--- a/src/addons/catalog.rs
+++ b/src/addons/catalog.rs
@@ -145,7 +145,14 @@ fn load(kind: AddonKind) -> Result {
fs::read_to_string(&source)
.map_err(|err| format!("Failed to read catalog '{}': {}", source, err))?
};
- parse(&body).map_err(|err| format!("Invalid catalog '{}': {}", source, err))
+ let catalog = parse(&body).map_err(|err| format!("Invalid catalog '{}': {}", source, err))?;
+ if catalog.schema_version != 1 {
+ return Err(format!(
+ "Unsupported catalog schema version {} in '{}'",
+ catalog.schema_version, source
+ ));
+ }
+ Ok(catalog)
}
fn catalog_source(kind: AddonKind) -> String {
From 4b632ccf47a8a41e6d855ca85db6fe09754aba6b Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 18:01:53 +0100
Subject: [PATCH 08/11] feat(packages): count xpm, xp and xpm-aur on Linux
---
CHANGELOG.md | 4 +
docs/CONFIGURATION.md | 12 ++
src/info/platform/linux/mod.rs | 1 +
src/info/platform/linux/packages.rs | 11 +-
src/info/platform/linux/xpm.rs | 223 ++++++++++++++++++++++++++++
5 files changed, 249 insertions(+), 2 deletions(-)
create mode 100644 src/info/platform/linux/xpm.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5310b20..be80e44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,10 @@
- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`.
- Updated the GitHub Actions group: `actions/checkout` to 7.0.1, `actions/setup-python` to 7.1.0 and the pinned `dtolnay/rust-toolchain` commit. All actions remain pinned by commit SHA.
+### Package counts
+
+- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read xpm's local database (`/var/lib/xpm/local`) and the xpm-aur state sidecar, so machines using the X package manager report their counts without extra subprocesses. `xp` is the subset installed from the native `x` repository; `xpm` remains the total. Existing managers are unchanged.
+
### Addon tooling
- `xfetch plugin`, `xfetch effects` and `xfetch extension` installs now print the configuration snippet required to enable the installed addon, along with its declared capabilities and limits.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 4fc241d..39437f6 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -153,6 +153,18 @@
packages:aurArch (pacman)
AUR and manually installed packages (pacman -Qm).
+
+ packages:xpmX Linux (xpm)
+ Installed packages in the xpm local database.
+
+
+ packages:xpX Linux (xpm)
+ Native X packages installed from the x repository (subset of xpm).
+
+
+ packages:xpm-aurX Linux (xpm-aur)
+ AUR packages tracked by xpm-aur.
+
packages:dpkgDebian, Ubuntu
apt packages.
diff --git a/src/info/platform/linux/mod.rs b/src/info/platform/linux/mod.rs
index 4048b7c..d46e9e5 100644
--- a/src/info/platform/linux/mod.rs
+++ b/src/info/platform/linux/mod.rs
@@ -11,6 +11,7 @@ pub mod live;
pub mod network;
pub mod os_release;
pub mod packages;
+mod xpm;
pub use battery::get_battery_info;
pub use datetime::get_datetime_info;
diff --git a/src/info/platform/linux/packages.rs b/src/info/platform/linux/packages.rs
index 735cb37..e9f5839 100644
--- a/src/info/platform/linux/packages.rs
+++ b/src/info/platform/linux/packages.rs
@@ -1,5 +1,6 @@
use std::path::Path;
+use crate::info::platform::linux::xpm;
use crate::info::platform::shared::packages::{
PACKAGE_CHECK_TIMEOUT, PackageCheck, SNAP_CHECK_TIMEOUT, format_package_count,
run_package_checks,
@@ -216,7 +217,7 @@ pub fn get_packages_breakdown() -> Vec<(String, String)> {
.cloned()
.collect();
let cmd_counts = run_package_checks(&pending);
- CHECKS
+ let mut breakdown: Vec<(String, String)> = CHECKS
.iter()
.filter_map(|check| {
if let Some((_, n)) = db_counts.iter().find(|(c, _)| *c == check.label) {
@@ -230,7 +231,13 @@ pub fn get_packages_breakdown() -> Vec<(String, String)> {
cmd_counts.iter().find(|(c, _)| c == check.label).cloned()
}
})
- .collect()
+ .collect();
+
+ for (label, count) in xpm::package_counts() {
+ breakdown.push((label.to_string(), format_package_count(count, label)));
+ }
+
+ breakdown
}
#[cfg(test)]
diff --git a/src/info/platform/linux/xpm.rs b/src/info/platform/linux/xpm.rs
new file mode 100644
index 0000000..0ba1ded
--- /dev/null
+++ b/src/info/platform/linux/xpm.rs
@@ -0,0 +1,223 @@
+//! Package counts for the X package manager (`xpm`) and its AUR helper
+//! (`xpm-aur`).
+//!
+//! `xpm` keeps its own local database at `/var/lib/xpm/local` and mirrors
+//! transactions into pacman's database. Each `/version` file marks a
+//! valid installed package and `/origin` records the repository it
+//! came from. The native X repository is `x`, so packages with that origin are
+//! reported as `xp` (native `.xp` packages). Packages pulled from Arch
+//! repositories through xpm's pacman compatibility layer still count towards
+//! the `xpm` total but not towards `xp`.
+//!
+//! `xpm-aur` tracks the AUR packages it installed in
+//! `$XDG_STATE_HOME/xpm-aur/state.json`; the count is the number of installed
+//! split outputs across all tracked package bases.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use serde::Deserialize;
+
+pub const XPM_LABEL: &str = "xpm";
+pub const XP_LABEL: &str = "xp";
+pub const XPM_AUR_LABEL: &str = "xpm-aur";
+
+const XPM_DB_DIR: &str = "/var/lib/xpm/local";
+const XPM_AUR_FALLBACK_DIR: &str = "/var/lib/xpm-aur";
+
+/// Repository names that publish native X packages. `x` is the native endpoint
+/// used by `xpm`'s example configuration; extend this list if more native
+/// repositories are added.
+const X_NATIVE_REPOS: &[&str] = &["x"];
+
+const VERSION_FILE: &str = "version";
+const ORIGIN_FILE: &str = "origin";
+
+#[derive(Debug, Default, Deserialize)]
+struct AurState {
+ #[serde(default)]
+ packages: BTreeMap,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct AurRecord {
+ #[serde(default)]
+ packages: Vec,
+}
+
+/// Returns the `xpm`, `xp` and `xpm-aur` counts that apply to this machine.
+///
+/// The list is empty when `xpm` has no local database. `xp` is always present
+/// alongside `xpm` (it may be zero) so the native package count is explicit.
+/// `xpm-aur` is only present when its state file can be read.
+pub fn package_counts() -> Vec<(&'static str, usize)> {
+ let db = Path::new(XPM_DB_DIR);
+ let Some(total) = count_xpm_packages(db) else {
+ return Vec::new();
+ };
+
+ let mut counts = vec![(XPM_LABEL, total)];
+
+ let native = count_xp_packages(db, X_NATIVE_REPOS).unwrap_or(0);
+ counts.push((XP_LABEL, native));
+
+ let state = xpm_aur_state_path();
+ if let Some(count) = state.as_deref().and_then(count_xpm_aur_packages) {
+ counts.push((XPM_AUR_LABEL, count));
+ }
+
+ counts
+}
+
+/// Counts directories in the xpm local database that contain a `version` file,
+/// matching what `xpm query` reports.
+fn count_xpm_packages(db: &Path) -> Option {
+ Some(
+ std::fs::read_dir(db)
+ .ok()?
+ .filter_map(|entry| entry.ok())
+ .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
+ .filter(|entry| !is_dot_entry(&entry.file_name()))
+ .filter(|entry| entry.path().join(VERSION_FILE).is_file())
+ .count(),
+ )
+}
+
+/// Counts installed packages whose recorded origin is a native X repository.
+fn count_xp_packages(db: &Path, native_repos: &[&str]) -> Option {
+ Some(
+ std::fs::read_dir(db)
+ .ok()?
+ .filter_map(|entry| entry.ok())
+ .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
+ .filter(|entry| !is_dot_entry(&entry.file_name()))
+ .filter(|entry| entry.path().join(VERSION_FILE).is_file())
+ .filter(|entry| {
+ std::fs::read_to_string(entry.path().join(ORIGIN_FILE))
+ .ok()
+ .is_some_and(|origin| native_repos.contains(&origin.trim()))
+ })
+ .count(),
+ )
+}
+
+/// Counts the installed package outputs recorded by `xpm-aur`.
+fn count_xpm_aur_packages(state_file: &Path) -> Option {
+ let text = std::fs::read_to_string(state_file).ok()?;
+ let state: AurState = serde_json::from_str(&text).ok()?;
+ Some(
+ state
+ .packages
+ .values()
+ .map(|record| record.packages.len().max(1))
+ .sum(),
+ )
+}
+
+fn xpm_aur_state_path() -> Option {
+ if let Some(path) = std::env::var_os("XPM_AUR_STATE") {
+ return Some(PathBuf::from(path));
+ }
+ let base = dirs::state_dir()
+ .or_else(|| dirs::home_dir().map(|home| home.join(".local/state")))
+ .unwrap_or_else(|| PathBuf::from(XPM_AUR_FALLBACK_DIR));
+ Some(base.join("xpm-aur/state.json"))
+}
+
+fn is_dot_entry(name: &std::ffi::OsStr) -> bool {
+ name.to_str().is_some_and(|name| name.starts_with('.'))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_dir(name: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("xfetch_test_xpm_{name}_{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ fn package(db: &Path, name: &str, origin: Option<&str>) {
+ let dir = db.join(name);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join(VERSION_FILE), "1.0-1").unwrap();
+ if let Some(origin) = origin {
+ std::fs::write(dir.join(ORIGIN_FILE), origin).unwrap();
+ }
+ }
+
+ #[test]
+ fn test_count_xpm_packages_skips_incomplete_entries() {
+ let db = temp_dir("xpm_total");
+ package(&db, "foo", Some("x"));
+ package(&db, "bar", Some("core"));
+ std::fs::create_dir_all(db.join("incomplete")).unwrap();
+ std::fs::create_dir_all(db.join(".partial")).unwrap();
+ std::fs::write(db.join(".partial").join(VERSION_FILE), "1.0").unwrap();
+ std::fs::write(db.join("db.lck"), "").unwrap();
+
+ assert_eq!(count_xpm_packages(&db), Some(2));
+ let _ = std::fs::remove_dir_all(&db);
+ }
+
+ #[test]
+ fn test_count_xp_packages_filters_by_native_origin() {
+ let db = temp_dir("xp_total");
+ package(&db, "native-a", Some("x"));
+ package(&db, "native-b", Some("x\n"));
+ package(&db, "arch", Some("core"));
+ package(&db, "local", Some("local"));
+ package(&db, "no-origin", None);
+
+ assert_eq!(count_xp_packages(&db, &["x"]), Some(2));
+ assert_eq!(count_xp_packages(&db, &["x", "x-extra"]), Some(2));
+ let _ = std::fs::remove_dir_all(&db);
+ }
+
+ #[test]
+ fn test_count_xpm_aur_packages_sums_outputs() {
+ let dir = temp_dir("aur_state");
+ let state = dir.join("state.json");
+ std::fs::write(
+ &state,
+ r#"{
+ "schema": 1,
+ "packages": {
+ "yay": {
+ "base": "yay",
+ "version": "1.0",
+ "commit": "abc",
+ "installed_at": 1,
+ "explicit": true,
+ "packages": ["yay", "yay-debug"]
+ },
+ "old": {
+ "base": "old",
+ "version": "2.0",
+ "commit": "def",
+ "installed_at": 2,
+ "explicit": false,
+ "packages": []
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(count_xpm_aur_packages(&state), Some(3));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_count_xpm_aur_packages_rejects_invalid_state() {
+ let dir = temp_dir("aur_invalid");
+ let state = dir.join("state.json");
+ std::fs::write(&state, "not json").unwrap();
+ assert_eq!(count_xpm_aur_packages(&state), None);
+ assert_eq!(count_xpm_aur_packages(&dir.join("missing.json")), None);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
From 2f1f0d0c2a7573057dfea9a937023aa24c64a427 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 18:12:46 +0100
Subject: [PATCH 09/11] fix(packages): count only locally installed xpm
packages
---
CHANGELOG.md | 2 +-
docs/CONFIGURATION.md | 2 +-
src/info/platform/linux/xpm.rs | 140 +++++++++++++++++++++------------
3 files changed, 93 insertions(+), 51 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be80e44..315491f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,7 +17,7 @@
### Package counts
-- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read xpm's local database (`/var/lib/xpm/local`) and the xpm-aur state sidecar, so machines using the X package manager report their counts without extra subprocesses. `xp` is the subset installed from the native `x` repository; `xpm` remains the total. Existing managers are unchanged.
+- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read only local installation state: xpm's local database (`/var/lib/xpm/local`) and the xpm-aur sidecar, whose outputs are intersected with that database so stale records are ignored. `xp` is the installed subset from the native `x` repository; `xpm` remains the total. Remote catalogs are never queried and existing managers are unchanged.
### Addon tooling
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 39437f6..01988d6 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -163,7 +163,7 @@
packages:xpm-aurX Linux (xpm-aur)
- AUR packages tracked by xpm-aur.
+ Installed AUR packages tracked by xpm-aur.
packages:dpkgDebian, Ubuntu
diff --git a/src/info/platform/linux/xpm.rs b/src/info/platform/linux/xpm.rs
index 0ba1ded..1154a5d 100644
--- a/src/info/platform/linux/xpm.rs
+++ b/src/info/platform/linux/xpm.rs
@@ -11,9 +11,11 @@
//!
//! `xpm-aur` tracks the AUR packages it installed in
//! `$XDG_STATE_HOME/xpm-aur/state.json`; the count is the number of installed
-//! split outputs across all tracked package bases.
+//! split outputs across all tracked package bases. The sidecar is intersected
+//! with the xpm local database so only packages that are actually installed
+//! are counted.
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use serde::Deserialize;
@@ -49,69 +51,89 @@ struct AurRecord {
///
/// The list is empty when `xpm` has no local database. `xp` is always present
/// alongside `xpm` (it may be zero) so the native package count is explicit.
-/// `xpm-aur` is only present when its state file can be read.
+/// `xpm-aur` is only present when its state file can be read. Every count is
+/// derived from locally installed packages; remote catalogs are never queried.
pub fn package_counts() -> Vec<(&'static str, usize)> {
let db = Path::new(XPM_DB_DIR);
- let Some(total) = count_xpm_packages(db) else {
+ let Some(installed) = installed_package_names(db) else {
return Vec::new();
};
- let mut counts = vec![(XPM_LABEL, total)];
+ let mut counts = vec![(XPM_LABEL, installed.len())];
- let native = count_xp_packages(db, X_NATIVE_REPOS).unwrap_or(0);
+ let native = count_xp_packages(db, &installed, X_NATIVE_REPOS);
counts.push((XP_LABEL, native));
let state = xpm_aur_state_path();
- if let Some(count) = state.as_deref().and_then(count_xpm_aur_packages) {
+ if let Some(count) = state
+ .as_deref()
+ .and_then(|path| count_xpm_aur_packages(path, &installed))
+ {
counts.push((XPM_AUR_LABEL, count));
}
counts
}
-/// Counts directories in the xpm local database that contain a `version` file,
-/// matching what `xpm query` reports.
-fn count_xpm_packages(db: &Path) -> Option {
- Some(
- std::fs::read_dir(db)
- .ok()?
- .filter_map(|entry| entry.ok())
- .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
- .filter(|entry| !is_dot_entry(&entry.file_name()))
- .filter(|entry| entry.path().join(VERSION_FILE).is_file())
- .count(),
- )
+/// Returns the names of packages installed in the xpm local database.
+///
+/// A directory counts only when it contains a `version` file, matching what
+/// `xpm query` reports, and dot-prefixed entries are ignored.
+fn installed_package_names(db: &Path) -> Option> {
+ let mut names = HashSet::new();
+
+ for entry in std::fs::read_dir(db).ok()?.filter_map(Result::ok) {
+ if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
+ continue;
+ }
+ if is_dot_entry(&entry.file_name()) {
+ continue;
+ }
+ if !entry.path().join(VERSION_FILE).is_file() {
+ continue;
+ }
+ names.insert(entry.file_name().to_string_lossy().into_owned());
+ }
+
+ Some(names)
}
/// Counts installed packages whose recorded origin is a native X repository.
-fn count_xp_packages(db: &Path, native_repos: &[&str]) -> Option {
- Some(
- std::fs::read_dir(db)
- .ok()?
- .filter_map(|entry| entry.ok())
- .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
- .filter(|entry| !is_dot_entry(&entry.file_name()))
- .filter(|entry| entry.path().join(VERSION_FILE).is_file())
- .filter(|entry| {
- std::fs::read_to_string(entry.path().join(ORIGIN_FILE))
- .ok()
- .is_some_and(|origin| native_repos.contains(&origin.trim()))
- })
- .count(),
- )
+fn count_xp_packages(db: &Path, installed: &HashSet, native_repos: &[&str]) -> usize {
+ installed
+ .iter()
+ .filter(|name| {
+ std::fs::read_to_string(db.join(name).join(ORIGIN_FILE))
+ .ok()
+ .is_some_and(|origin| native_repos.contains(&origin.trim()))
+ })
+ .count()
}
/// Counts the installed package outputs recorded by `xpm-aur`.
-fn count_xpm_aur_packages(state_file: &Path) -> Option {
+///
+/// Records and split outputs that are no longer present in the xpm local
+/// database are ignored, so stale sidecar entries do not inflate the count.
+fn count_xpm_aur_packages(state_file: &Path, installed: &HashSet) -> Option {
let text = std::fs::read_to_string(state_file).ok()?;
let state: AurState = serde_json::from_str(&text).ok()?;
- Some(
- state
- .packages
- .values()
- .map(|record| record.packages.len().max(1))
- .sum(),
- )
+
+ let mut count = 0;
+ for (base, record) in &state.packages {
+ if record.packages.is_empty() {
+ if installed.contains(base) {
+ count += 1;
+ }
+ } else {
+ count += record
+ .packages
+ .iter()
+ .filter(|name| installed.contains(*name))
+ .count();
+ }
+ }
+
+ Some(count)
}
fn xpm_aur_state_path() -> Option {
@@ -150,7 +172,7 @@ mod tests {
}
#[test]
- fn test_count_xpm_packages_skips_incomplete_entries() {
+ fn test_installed_package_names_skips_incomplete_entries() {
let db = temp_dir("xpm_total");
package(&db, "foo", Some("x"));
package(&db, "bar", Some("core"));
@@ -159,7 +181,10 @@ mod tests {
std::fs::write(db.join(".partial").join(VERSION_FILE), "1.0").unwrap();
std::fs::write(db.join("db.lck"), "").unwrap();
- assert_eq!(count_xpm_packages(&db), Some(2));
+ let installed = installed_package_names(&db).unwrap();
+ assert_eq!(installed.len(), 2);
+ assert!(installed.contains("foo"));
+ assert!(installed.contains("bar"));
let _ = std::fs::remove_dir_all(&db);
}
@@ -172,8 +197,9 @@ mod tests {
package(&db, "local", Some("local"));
package(&db, "no-origin", None);
- assert_eq!(count_xp_packages(&db, &["x"]), Some(2));
- assert_eq!(count_xp_packages(&db, &["x", "x-extra"]), Some(2));
+ let installed = installed_package_names(&db).unwrap();
+ assert_eq!(count_xp_packages(&db, &installed, &["x"]), 2);
+ assert_eq!(count_xp_packages(&db, &installed, &["x", "x-extra"]), 2);
let _ = std::fs::remove_dir_all(&db);
}
@@ -201,13 +227,25 @@ mod tests {
"installed_at": 2,
"explicit": false,
"packages": []
+ },
+ "stale": {
+ "base": "stale",
+ "version": "3.0",
+ "commit": "ghi",
+ "installed_at": 3,
+ "explicit": false,
+ "packages": ["stale-pkg"]
}
}
}"#,
)
.unwrap();
- assert_eq!(count_xpm_aur_packages(&state), Some(3));
+ let installed: HashSet = ["yay", "yay-debug", "old"]
+ .into_iter()
+ .map(String::from)
+ .collect();
+ assert_eq!(count_xpm_aur_packages(&state, &installed), Some(3));
let _ = std::fs::remove_dir_all(&dir);
}
@@ -216,8 +254,12 @@ mod tests {
let dir = temp_dir("aur_invalid");
let state = dir.join("state.json");
std::fs::write(&state, "not json").unwrap();
- assert_eq!(count_xpm_aur_packages(&state), None);
- assert_eq!(count_xpm_aur_packages(&dir.join("missing.json")), None);
+ let installed = HashSet::new();
+ assert_eq!(count_xpm_aur_packages(&state, &installed), None);
+ assert_eq!(
+ count_xpm_aur_packages(&dir.join("missing.json"), &installed),
+ None
+ );
let _ = std::fs::remove_dir_all(&dir);
}
}
From cec680914b0c1a912705b0cf5f3a5b53c6ea5bec Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 18:41:35 +0100
Subject: [PATCH 10/11] fix(packages): read shared xpm and pacman databases
---
CHANGELOG.md | 2 +-
docs/CONFIGURATION.md | 4 +-
src/info/platform/linux/packages.rs | 13 +++
src/info/platform/linux/xpm.rs | 126 ++++++++++++++++++++++++----
4 files changed, 127 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 315491f..ccea74a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,7 +17,7 @@
### Package counts
-- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read only local installation state: xpm's local database (`/var/lib/xpm/local`) and the xpm-aur sidecar, whose outputs are intersected with that database so stale records are ignored. `xp` is the installed subset from the native `x` repository; `xpm` remains the total. Remote catalogs are never queried and existing managers are unchanged.
+- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read only local installation state: xpm's database (`/var/lib/xpm/local`) and pacman's local database (`/var/lib/pacman/local`), which xpm mirrors its transactions into, so packages from both managers are counted once. `xp` is the installed subset from the native `x` repository; `xpm` is the shared total. The xpm-aur sidecar is intersected with that set so stale records are ignored. When the `pacman` binary is unavailable, the local database is counted directly instead of showing `unknown`. Remote catalogs are never queried and existing managers are unchanged.
### Addon tooling
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 01988d6..1c23f39 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -147,7 +147,7 @@
packages:pacmanArch (pacman)
- Official repository packages only (pacman -Qn).
+ Official repository packages only (pacman -Qn); falls back to the local database when the binary is unavailable.
packages:aurArch (pacman)
@@ -155,7 +155,7 @@
packages:xpmX Linux (xpm)
- Installed packages in the xpm local database.
+ Installed packages visible to xpm (its database plus the pacman local database it shares).
packages:xpX Linux (xpm)
diff --git a/src/info/platform/linux/packages.rs b/src/info/platform/linux/packages.rs
index e9f5839..8bf881b 100644
--- a/src/info/platform/linux/packages.rs
+++ b/src/info/platform/linux/packages.rs
@@ -237,6 +237,19 @@ pub fn get_packages_breakdown() -> Vec<(String, String)> {
breakdown.push((label.to_string(), format_package_count(count, label)));
}
+ let covered = breakdown
+ .iter()
+ .any(|(label, _)| label == xpm::XPM_LABEL || label == PACMAN_CMD);
+ if !covered && let Some(count) = xpm::pacman_db_package_count() {
+ breakdown.insert(
+ 0,
+ (
+ PACMAN_CMD.to_string(),
+ format_package_count(count, PACMAN_CMD),
+ ),
+ );
+ }
+
breakdown
}
diff --git a/src/info/platform/linux/xpm.rs b/src/info/platform/linux/xpm.rs
index 1154a5d..fd392c0 100644
--- a/src/info/platform/linux/xpm.rs
+++ b/src/info/platform/linux/xpm.rs
@@ -2,17 +2,22 @@
//! (`xpm-aur`).
//!
//! `xpm` keeps its own local database at `/var/lib/xpm/local` and mirrors
-//! transactions into pacman's database. Each `/version` file marks a
-//! valid installed package and `/origin` records the repository it
-//! came from. The native X repository is `x`, so packages with that origin are
-//! reported as `xp` (native `.xp` packages). Packages pulled from Arch
-//! repositories through xpm's pacman compatibility layer still count towards
-//! the `xpm` total but not towards `xp`.
+//! transactions into pacman's local database at `/var/lib/pacman/local`. The
+//! `xpm` count is the union of both databases, which is the locally installed
+//! set xpm can see; pacman-only packages are included because xpm shares that
+//! database through the mirror.
+//!
+//! Each xpm `/version` file marks a valid installed package and
+//! `/origin` records the repository it came from. The native X
+//! repository is `x`, so packages with that origin are reported as `xp`
+//! (native `.xp` packages). Packages pulled from Arch repositories through
+//! xpm's pacman compatibility layer still count towards the `xpm` total but
+//! not towards `xp`.
//!
//! `xpm-aur` tracks the AUR packages it installed in
//! `$XDG_STATE_HOME/xpm-aur/state.json`; the count is the number of installed
//! split outputs across all tracked package bases. The sidecar is intersected
-//! with the xpm local database so only packages that are actually installed
+//! with the shared installed set so only packages that are actually installed
//! are counted.
use std::collections::{BTreeMap, HashSet};
@@ -25,6 +30,10 @@ pub const XP_LABEL: &str = "xp";
pub const XPM_AUR_LABEL: &str = "xpm-aur";
const XPM_DB_DIR: &str = "/var/lib/xpm/local";
+const PACMAN_DB_DIR: &str = "/var/lib/pacman/local";
+const PACMAN_DESC_FILE: &str = "desc";
+const PACMAN_NAME_TAG: &str = "%NAME%";
+const XPM_BINARY: &str = "xpm";
const XPM_AUR_FALLBACK_DIR: &str = "/var/lib/xpm-aur";
/// Repository names that publish native X packages. `x` is the native endpoint
@@ -49,19 +58,25 @@ struct AurRecord {
/// Returns the `xpm`, `xp` and `xpm-aur` counts that apply to this machine.
///
-/// The list is empty when `xpm` has no local database. `xp` is always present
-/// alongside `xpm` (it may be zero) so the native package count is explicit.
-/// `xpm-aur` is only present when its state file can be read. Every count is
-/// derived from locally installed packages; remote catalogs are never queried.
+/// The list is empty when xpm is not present. `xp` is always present alongside
+/// `xpm` (it may be zero) so the native package count is explicit. `xpm-aur`
+/// is only present when its state file can be read. Every count is derived
+/// from locally installed packages; remote catalogs are never queried.
pub fn package_counts() -> Vec<(&'static str, usize)> {
- let db = Path::new(XPM_DB_DIR);
- let Some(installed) = installed_package_names(db) else {
+ let xpm_db = Path::new(XPM_DB_DIR);
+ let xpm_names = installed_package_names(xpm_db).unwrap_or_default();
+ let pacman_names = pacman_package_names(Path::new(PACMAN_DB_DIR)).unwrap_or_default();
+
+ if xpm_names.is_empty() && !xpm_db.is_dir() && !command_exists(XPM_BINARY) {
return Vec::new();
- };
+ }
+
+ let mut installed = xpm_names.clone();
+ installed.extend(pacman_names);
let mut counts = vec![(XPM_LABEL, installed.len())];
- let native = count_xp_packages(db, &installed, X_NATIVE_REPOS);
+ let native = count_xp_packages(xpm_db, &xpm_names, X_NATIVE_REPOS);
counts.push((XP_LABEL, native));
let state = xpm_aur_state_path();
@@ -98,6 +113,66 @@ fn installed_package_names(db: &Path) -> Option> {
Some(names)
}
+/// Returns the names of packages in pacman's local database.
+///
+/// pacman names its entries `--` and stores the
+/// exact name in `desc`; xpm-mirrored entries also land here in that format.
+/// xpm-style entries (`/version`) are accepted too, in case xpm is
+/// configured to use this directory directly.
+fn pacman_package_names(db: &Path) -> Option> {
+ let mut names = HashSet::new();
+
+ for entry in std::fs::read_dir(db).ok()?.filter_map(Result::ok) {
+ if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
+ continue;
+ }
+ if is_dot_entry(&entry.file_name()) {
+ continue;
+ }
+
+ if entry.path().join(VERSION_FILE).is_file() {
+ names.insert(entry.file_name().to_string_lossy().into_owned());
+ continue;
+ }
+
+ if let Some(name) = pacman_desc_name(&entry.path()) {
+ names.insert(name);
+ }
+ }
+
+ Some(names)
+}
+
+/// Counts the packages in pacman's local database.
+///
+/// Used as a fallback when neither xpm nor the `pacman` command is available
+/// but the shared database still exists.
+pub(crate) fn pacman_db_package_count() -> Option {
+ pacman_package_names(Path::new(PACMAN_DB_DIR)).map(|names| names.len())
+}
+
+fn pacman_desc_name(pkg_dir: &Path) -> Option {
+ let desc = std::fs::read_to_string(pkg_dir.join(PACMAN_DESC_FILE)).ok()?;
+ let mut lines = desc.lines();
+
+ while let Some(line) = lines.next() {
+ if line.trim() == PACMAN_NAME_TAG {
+ return lines
+ .next()
+ .map(str::trim)
+ .filter(|name| !name.is_empty())
+ .map(str::to_string);
+ }
+ }
+
+ None
+}
+
+fn command_exists(name: &str) -> bool {
+ std::env::var_os("PATH")
+ .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
+}
+
/// Counts installed packages whose recorded origin is a native X repository.
fn count_xp_packages(db: &Path, installed: &HashSet, native_repos: &[&str]) -> usize {
installed
@@ -188,6 +263,27 @@ mod tests {
let _ = std::fs::remove_dir_all(&db);
}
+ #[test]
+ fn test_pacman_package_names_reads_desc_and_xpm_style_entries() {
+ let db = temp_dir("pacman_total");
+ let desc_dir = db.join("foo-1.0-1");
+ std::fs::create_dir_all(&desc_dir).unwrap();
+ std::fs::write(
+ desc_dir.join(PACMAN_DESC_FILE),
+ "%NAME%\nfoo\n\n%VERSION%\n1.0-1\n",
+ )
+ .unwrap();
+ package(&db, "bar", None);
+ std::fs::create_dir_all(db.join("incomplete-1.0-1")).unwrap();
+ std::fs::write(db.join("db.lck"), "").unwrap();
+
+ let names = pacman_package_names(&db).unwrap();
+ assert_eq!(names.len(), 2);
+ assert!(names.contains("foo"));
+ assert!(names.contains("bar"));
+ let _ = std::fs::remove_dir_all(&db);
+ }
+
#[test]
fn test_count_xp_packages_filters_by_native_origin() {
let db = temp_dir("xp_total");
From 22cd5efd5b078ddd1f76876a03582ddd6dcb2817 Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Tue, 3 Nov 2026 19:17:33 +0100
Subject: [PATCH 11/11] feat(cli): add --list-layouts and fix layout defaults
---
CHANGELOG.md | 4 ++++
docs/CONFIGURATION.md | 1 +
docs/GEN_CONFIG.md | 7 ++++---
docs/LAYOUTS.md | 2 ++
src/cli.rs | 9 +++++++--
src/main.rs | 7 +++++++
src/ui.rs | 2 +-
src/ui/layout.rs | 5 +++++
8 files changed, 31 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccea74a..47610f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,10 @@
- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read only local installation state: xpm's database (`/var/lib/xpm/local`) and pacman's local database (`/var/lib/pacman/local`), which xpm mirrors its transactions into, so packages from both managers are counted once. `xp` is the installed subset from the native `x` repository; `xpm` is the shared total. The xpm-aur sidecar is intersected with that set so stale records are ignored. When the `pacman` binary is unavailable, the local database is counted directly instead of showing `unknown`. Remote catalogs are never queried and existing managers are unchanged.
+### CLI
+
+- Added `xfetch --list-layouts`, which prints every name accepted by `--layout`. Corrected the `--layout` help: `--gen-config` defaults to the `section` layout, and unknown names keep that fallback.
+
### Addon tooling
- `xfetch plugin`, `xfetch effects` and `xfetch extension` installs now print the configuration snippet required to enable the installed addon, along with its declared capabilities and limits.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 1c23f39..ab6d858 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -42,6 +42,7 @@
xfetch --daemon-live-stopStop the running live stats daemon.
xfetch --daemon-live-reloadForce hot reload in the live stats daemon (same as "daemon_live_reload": true).
xfetch --gen-configGenerate a starter config at the default location. See GEN_CONFIG.md.
+ xfetch --list-layoutsPrint the layout names accepted by --layout.
xfetch --clean-cacheClear the module cache.
xfetch --benchmarkPrint benchmarking info for the info gathering step.
xfetch plugin install|list|remove <name>Manage plugins. See PLUGINS.md.
diff --git a/docs/GEN_CONFIG.md b/docs/GEN_CONFIG.md
index 12a7de8..915d7a9 100644
--- a/docs/GEN_CONFIG.md
+++ b/docs/GEN_CONFIG.md
@@ -43,7 +43,7 @@
{
"ascii": "/home/jan/.config/xfetch/logos/ubuntu.txt",
- "layout": "pacman",
+ "layout": "section",
// ...
}
@@ -92,7 +92,8 @@ xfetch --gen-config --logo macos-ventura
The template ships with the section layout (grouped Hardware/Software/Session
modules). Use --layout to generate it with any of the built-in layouts (see
- LAYOUTS.md):
+ LAYOUTS.md). Run xfetch --list-layouts to print every
+ accepted name:
xfetch --gen-config --layout pacman
@@ -108,7 +109,7 @@ xfetch --gen-config --layout compact
horizontal, bottom (vertical layouts)
-An unknown layout name warns and keeps pacman.
+An unknown layout name warns and keeps section.
Combined Example
diff --git a/docs/LAYOUTS.md b/docs/LAYOUTS.md
index 2f46875..f998eac 100644
--- a/docs/LAYOUTS.md
+++ b/docs/LAYOUTS.md
@@ -6,6 +6,8 @@
Built-in Layouts
+Run xfetch --list-layouts to print every accepted layout name.
+
1. Default Layout (Classic)
diff --git a/src/cli.rs b/src/cli.rs
index 1c42eb0..8a93443 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,7 @@ use clap::{Parser, Subcommand};
version,
about,
long_about = None,
- after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin search logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects available\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension search night\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme"
+ after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --gen-config --layout tree\n xfetch --list-layouts\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin search logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects available\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension search night\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme"
)]
pub struct Cli {
#[command(subcommand)]
@@ -25,10 +25,15 @@ pub struct Cli {
pub logo: Option,
/// Layout for the generated config (e.g. `section`, `tree`, `compact`).
- /// Only used with `--gen-config`; defaults to `pacman`.
+ /// Only used with `--gen-config`; defaults to `section`. Run
+ /// `xfetch --list-layouts` to see every accepted name.
#[arg(long, global = true)]
pub layout: Option,
+ /// Print the layout names accepted by `--layout` and exit.
+ #[arg(long, global = true)]
+ pub list_layouts: bool,
+
#[arg(long, global = true)]
pub clean_cache: bool,
diff --git a/src/main.rs b/src/main.rs
index 1ef0009..dbff34b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -39,6 +39,13 @@ fn main() {
}
fn run(cli: Cli) -> Result<(), error::XFetchError> {
+ if cli.list_layouts {
+ for name in crate::ui::LAYOUT_NAMES {
+ println!("{name}");
+ }
+ return Ok(());
+ }
+
if cli.clean_cache {
return cache::clean()
.map_err(|err| error::XFetchError::CleanCache(err.to_string()))
diff --git a/src/ui.rs b/src/ui.rs
index c74b6bf..b9dea7a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -20,7 +20,7 @@ mod win_daemon;
mod x;
#[cfg(unix)]
pub use daemon::{draw_daemon, stop_daemon};
-pub use layout::is_known_layout;
+pub use layout::{LAYOUT_NAMES, is_known_layout};
#[cfg(unix)]
pub use live::{draw_live_daemon, stop_live_daemon};
#[cfg(windows)]
diff --git a/src/ui/layout.rs b/src/ui/layout.rs
index 12dfb85..2710f11 100644
--- a/src/ui/layout.rs
+++ b/src/ui/layout.rs
@@ -81,6 +81,11 @@ mod tests {
use super::*;
use crate::config::Config;
+ #[test]
+ fn test_layout_names_include_generated_default() {
+ assert!(LAYOUT_NAMES.contains(&"section"));
+ }
+
#[test]
fn test_get_content_lines_empty() {
// Verify that the layout correctly handles an empty list of nodes without crashing