diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..e68ff1e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,27 @@
+# Auto-detect text files and always use LF everywhere (Linux, macOS and
+# Windows). CRLF is never checked out, so no autocrlf warnings on Windows.
+* text=auto eol=lf
+
+# Rust source code
+*.rs linguist-language=Rust text
+
+# Configuration files
+*.toml linguist-language=TOML text
+*.jsonc linguist-language=JSON text
+*.json linguist-language=JSON text
+Cargo.lock linguist-language=TOML text
+
+# Scripts and CI
+*.sh linguist-language=Shell text eol=lf
+*.ps1 linguist-language=PowerShell text
+*.yml linguist-language=YAML text
+*.yaml linguist-language=YAML text
+
+# Documentation
+*.md linguist-language=Markdown text
+LICENSE linguist-language=Text text
+*.txt linguist-language=Text text
+
+# Git configuration
+.gitignore linguist-language=Ignore-List text
+.gitattributes linguist-language=Git-Attributes text
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..bec951b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Changelog
+
+## 2026-08-19
+
+### Timeout Standard
+
+- Both extensions wrap their work in `with_timeout` with a 2 s budget; on timeout they exit with an error instead of hanging the config load.
+- An extension without a runtime limit is rejected — enforced by CI (`ci/unix.sh`, `ci/windows.ps1`, running on Linux, macOS and Windows). PRs must pass CI.
+- Requires `xfetch-extension-api` with `with_timeout` (see the `api` repo).
+
+### Extensions (as of 2026-08-19)
+
+- `config-roulette` — picks a config from a routes list (random or daily); `~` expansion fixed on Windows (`USERPROFILE` fallback)
+- `layout-override` — overrides layout and/or modules at load time
+
+Each extension has its own CHANGELOG with its specific changes.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bdf0cf7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,48 @@
+
Contributing Extensions
+
+
+ Thanks for contributing to the xfetch extension ecosystem.
+ This repository contains the official config-provider extensions.
+
+
+Workflow
+
+
+ - Fork the repository and create a feature branch.
+ - Create or update an extension directory at
extensions/<name>.
+ - Run
cargo test --workspace.
+ -
+ Run the full CI locally before opening the PR:
+
bash ci/unix.sh (Linux/macOS) or ./ci/windows.ps1
+ (Windows). The CI checks tests and the extension standard.
+
+ - Document the extension in its own
README.md and in the repository README.md.
+ -
+ Open a pull request with usage details and any required external
+ dependencies. PRs that fail CI are rejected.
+
+
+
+Extension Rules
+
+
+ - Use the binary naming convention
xfetch-extension-<name>.
+ - Keep extensions focused on a single responsibility.
+ - Write errors to stderr and exit with a non-zero status on failure.
+ - Prefer stable, actively maintained dependencies and keep them minimal.
+ -
+ Every extension MUST have a runtime limit. Wrap all work
+ in
with_timeout (from xfetch_extension_api)
+ with a const BUDGET that fits the extension and exit with an
+ error when the budget elapses. An extension without a timeout is
+ rejected: it could hang the config load forever. This is enforced by CI.
+
+
+
+Protocol Guide
+
+
+ The full stdin/stdout JSON protocol is defined in the
+ xfetch-cli/api repository
+ (crates/extension-api).
+
diff --git a/ci/unix.sh b/ci/unix.sh
new file mode 100644
index 0000000..97dc165
--- /dev/null
+++ b/ci/unix.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# CI for Linux/macOS: build, test and enforce the extension standard.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+cargo test --workspace
+
+# Standard: every extension must wrap its work in with_timeout (CONTRIBUTING.md).
+for f in extensions/*/src/main.rs; do
+ grep -q "with_timeout" "$f" || {
+ echo "::error::$f must use xfetch_extension_api::with_timeout" >&2
+ exit 1
+ }
+done
+echo "All extensions use with_timeout."
diff --git a/ci/windows.ps1 b/ci/windows.ps1
new file mode 100644
index 0000000..dcf91fd
--- /dev/null
+++ b/ci/windows.ps1
@@ -0,0 +1,14 @@
+# CI for Windows: build, test and enforce the extension standard.
+$ErrorActionPreference = "Stop"
+Set-Location (Join-Path $PSScriptRoot "..")
+
+cargo test --workspace
+
+# Standard: every extension must wrap its work in with_timeout (CONTRIBUTING.md).
+foreach ($f in Get-ChildItem "extensions\*\src\main.rs") {
+ if (-not (Select-String -Path $f.FullName -Pattern "with_timeout" -Quiet)) {
+ Write-Error "$($f.FullName) must use xfetch_extension_api::with_timeout"
+ exit 1
+ }
+}
+Write-Host "All extensions use with_timeout."
diff --git a/extensions/config-roulette/CHANGELOG.md b/extensions/config-roulette/CHANGELOG.md
new file mode 100644
index 0000000..26f6130
--- /dev/null
+++ b/extensions/config-roulette/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 2026-08-19
+- Wrapped work in `with_timeout` with a 2 s budget; on timeout it exits with an error instead of hanging.
+- Fixed `~` expansion on Windows (falls back to `USERPROFILE` when `HOME` is unset).
diff --git a/extensions/config-roulette/src/main.rs b/extensions/config-roulette/src/main.rs
index fdf364d..0811eb9 100644
--- a/extensions/config-roulette/src/main.rs
+++ b/extensions/config-roulette/src/main.rs
@@ -1,6 +1,9 @@
use serde::Deserialize;
use std::io::{Read, Write};
-use xfetch_extension_api::{ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER};
+use std::time::Duration;
+use xfetch_extension_api::{
+ ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER, with_timeout,
+};
#[derive(Debug, Deserialize)]
struct Route {
@@ -27,85 +30,98 @@ fn default_strategy() -> Strategy {
Strategy::Daily
}
+/// Local file reads only; 2 s is plenty.
+const BUDGET: Duration = Duration::from_secs(2);
+
fn main() {
- let request: ConfigProviderRequest = match read_stdin() {
- Ok(v) => v,
- Err(err) => {
- eprintln!("{}", err);
+ let result = with_timeout(BUDGET, || {
+ let request: ConfigProviderRequest = match read_stdin() {
+ Ok(v) => v,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ if request.kind != KIND_CONFIG_PROVIDER {
+ eprintln!("Unsupported kind: {}", request.kind);
std::process::exit(1);
}
- };
-
- if request.kind != KIND_CONFIG_PROVIDER {
- eprintln!("Unsupported kind: {}", request.kind);
- std::process::exit(1);
- }
- let args: RouletteArgs = request
- .args
- .as_ref()
- .and_then(|v| serde_json::from_value(v.clone()).ok())
- .unwrap_or_else(|| RouletteArgs {
- routes: "~/.config/xfetch/routes.json".to_string(),
- strategy: default_strategy(),
- });
-
- let routes_path = expand_path(&args.routes);
- let content = match std::fs::read_to_string(&routes_path) {
- Ok(c) => c,
- Err(err) => {
- eprintln!("Failed to read routes file '{}': {}", routes_path.display(), err);
+ let args: RouletteArgs = request
+ .args
+ .as_ref()
+ .and_then(|v| serde_json::from_value(v.clone()).ok())
+ .unwrap_or_else(|| RouletteArgs {
+ routes: "~/.config/xfetch/routes.json".to_string(),
+ strategy: default_strategy(),
+ });
+
+ let routes_path = expand_path(&args.routes);
+ let content = match std::fs::read_to_string(&routes_path) {
+ Ok(c) => c,
+ Err(err) => {
+ eprintln!("Failed to read routes file '{}': {}", routes_path.display(), err);
+ std::process::exit(1);
+ }
+ };
+
+ let all_routes: Vec = match serde_json::from_str(&content) {
+ Ok(r) => r,
+ Err(err) => {
+ eprintln!("Failed to parse routes file: {}", err);
+ std::process::exit(1);
+ }
+ };
+
+ if all_routes.is_empty() {
+ eprintln!("Routes file is empty");
std::process::exit(1);
}
- };
- let all_routes: Vec = match serde_json::from_str(&content) {
- Ok(r) => r,
- Err(err) => {
- eprintln!("Failed to parse routes file: {}", err);
+ let index = match args.strategy {
+ Strategy::Random => {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .subsec_nanos();
+ (nanos as usize) % all_routes.len()
+ }
+ Strategy::Daily => {
+ let now = chrono_day();
+ (now as usize) % all_routes.len()
+ }
+ };
+
+ let chosen = &all_routes[index];
+ let config_path = expand_path(&chosen.path);
+
+ let config_content = match std::fs::read_to_string(&config_path) {
+ Ok(c) => c,
+ Err(err) => {
+ eprintln!("Failed to read config '{}': {}", config_path.display(), err);
+ std::process::exit(1);
+ }
+ };
+
+ let config: serde_json::Value = match serde_json::from_str(&config_content) {
+ Ok(v) => v,
+ Err(err) => {
+ eprintln!("Failed to parse config '{}': {}", config_path.display(), err);
+ std::process::exit(1);
+ }
+ };
+
+ config
+ });
+
+ match result {
+ Ok(config) => write_stdout(&ConfigProviderResponse { config }),
+ Err(_) => {
+ eprintln!("config-roulette: timed out");
std::process::exit(1);
}
- };
-
- if all_routes.is_empty() {
- eprintln!("Routes file is empty");
- std::process::exit(1);
}
-
- let index = match args.strategy {
- Strategy::Random => {
- let nanos = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap_or_default()
- .subsec_nanos();
- (nanos as usize) % all_routes.len()
- }
- Strategy::Daily => {
- let now = chrono_day();
- (now as usize) % all_routes.len()
- }
- };
-
- let chosen = &all_routes[index];
- let config_path = expand_path(&chosen.path);
-
- let config_content = match std::fs::read_to_string(&config_path) {
- Ok(c) => c,
- Err(err) => {
- eprintln!("Failed to read config '{}': {}", config_path.display(), err);
- std::process::exit(1);
- }
- };
-
- let config: serde_json::Value = match serde_json::from_str(&config_content) {
- Ok(v) => v,
- Err(err) => {
- eprintln!("Failed to parse config '{}': {}", config_path.display(), err);
- std::process::exit(1);
- }
- };
-
- write_stdout(&ConfigProviderResponse { config });
}
fn chrono_day() -> u32 {
@@ -118,7 +134,9 @@ fn chrono_day() -> u32 {
fn expand_path(path: &str) -> std::path::PathBuf {
if let Some(rest) = path.strip_prefix('~') {
- if let Ok(home) = std::env::var("HOME") {
+ // Windows has no HOME; USERPROFILE is the equivalent.
+ let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"));
+ if let Ok(home) = home {
return std::path::PathBuf::from(home).join(rest.strip_prefix('/').unwrap_or(rest));
}
}
diff --git a/extensions/layout-override/CHANGELOG.md b/extensions/layout-override/CHANGELOG.md
new file mode 100644
index 0000000..4ff6be2
--- /dev/null
+++ b/extensions/layout-override/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Changelog
+
+## 2026-08-19
+- Wrapped work in `with_timeout` with a 2 s budget; on timeout it exits with an error instead of hanging.
diff --git a/extensions/layout-override/src/main.rs b/extensions/layout-override/src/main.rs
index d33daed..193679a 100644
--- a/extensions/layout-override/src/main.rs
+++ b/extensions/layout-override/src/main.rs
@@ -1,6 +1,9 @@
use serde::Deserialize;
use std::io::{Read, Write};
-use xfetch_extension_api::{ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER};
+use std::time::Duration;
+use xfetch_extension_api::{
+ ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER, with_timeout,
+};
#[derive(Debug, Default, Deserialize)]
struct LayoutOverrideArgs {
@@ -9,42 +12,56 @@ struct LayoutOverrideArgs {
modules: Option>,
}
+/// Pure config transformation; 2 s is plenty.
+const BUDGET: Duration = Duration::from_secs(2);
+
fn main() {
- let request: ConfigProviderRequest = match read_stdin() {
- Ok(v) => v,
- Err(err) => {
- eprintln!("{}", err);
+ let result = with_timeout(BUDGET, || {
+ let request: ConfigProviderRequest = match read_stdin() {
+ Ok(v) => v,
+ Err(err) => {
+ eprintln!("{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ if request.kind != KIND_CONFIG_PROVIDER {
+ eprintln!("Unsupported kind: {}", request.kind);
std::process::exit(1);
}
- };
- if request.kind != KIND_CONFIG_PROVIDER {
- eprintln!("Unsupported kind: {}", request.kind);
- std::process::exit(1);
- }
+ let args: LayoutOverrideArgs = request
+ .args
+ .as_ref()
+ .and_then(|v| serde_json::from_value(v.clone()).ok())
+ .unwrap_or_default();
- let args: LayoutOverrideArgs = request
- .args
- .as_ref()
- .and_then(|v| serde_json::from_value(v.clone()).ok())
- .unwrap_or_default();
+ let mut config = request.config.clone();
- let mut config = request.config.clone();
+ if let Some(layout) = &args.layout {
+ if let Some(obj) = config.as_object_mut() {
+ obj.insert("layout".to_string(), serde_json::json!(layout));
+ }
+ }
- if let Some(layout) = &args.layout {
- if let Some(obj) = config.as_object_mut() {
- obj.insert("layout".to_string(), serde_json::json!(layout));
+ if let Some(modules) = &args.modules {
+ let mods: Vec =
+ modules.iter().map(|m| serde_json::json!(m)).collect();
+ if let Some(obj) = config.as_object_mut() {
+ obj.insert("modules".to_string(), serde_json::Value::Array(mods));
+ }
}
- }
- if let Some(modules) = &args.modules {
- let mods: Vec = modules.iter().map(|m| serde_json::json!(m)).collect();
- if let Some(obj) = config.as_object_mut() {
- obj.insert("modules".to_string(), serde_json::Value::Array(mods));
+ config
+ });
+
+ match result {
+ Ok(config) => write_stdout(&ConfigProviderResponse { config }),
+ Err(_) => {
+ eprintln!("layout-override: timed out");
+ std::process::exit(1);
}
}
-
- write_stdout(&ConfigProviderResponse { config });
}
fn read_stdin() -> Result {