zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
# Changelog

## [0.20.0] - 2026-08-06

### Added
- New `Platform` module: a capabilities-first model for host facts (OS identity, distro `ID`/`ID_LIKE` chain, version, arch) that degrades gracefully on unknown distros instead of failing.
- Host matching (`when = "..."` conditions) now follows the distro's `ID_LIKE` chain, so e.g. `when = "ubuntu"` also matches Pop!_OS and other Ubuntu derivatives.
- Fedora and Ubuntu OS-version matching now accepts any well-formed version (`fedoraN`, `ubuntuMAJOR.MINOR`) instead of a fixed list of known releases.
- `import` now walks an interactive per-row picker instead of a single wholesale "Apply this plan? [y/N]" prompt — cursor through every proposed file, rename, and cleanup and cycle each between its default and a skip/keep counterpart before applying. `--yes` and `--dry-run` continue to skip the picker.

### Changed
- **Breaking:** renamed the `dnf5` install-hint config key to `dnf` (every Fedora release still receiving updates ships DNF5, so the version suffix was dead weight). Configs using `[install_hint.dnf5]` must rename it to `[install_hint.dnf]`; the old key is now rejected.
- The primary package manager is now chosen from the host's actual distro identity rather than a best-guess order.

### Fixed
- Fixed `OsPattern` schema rejecting distro ids that otherwise parsed fine.
- Fixed tilde expansion to only expand a *leading* `~`, keeping the rest of the path intact under `$HOME`.
- Fixed `${brew_prefix}` resolution to no longer require `brew` on `PATH`.

**Full Changelog**: https://github.com/bjorn-ove/zenops/compare/zenops-v0.19.0...zenops-v0.20.0

## [0.19.0] - 2026-05-22

### Changed
- Minimum supported Rust version lowered from 1.95.0 to 1.89.0.
- **Breaking (library consumers):** `real_main` now takes a `&HostInputs<'_>` bundle (`dirs`, `path`, `args`) instead of those three parameters separately. Callers must construct a `HostInputs` and pass it.

### Added
- New `HostInputs` struct at the crate root, bundling host-environment inputs (config-file roots, binary search path, CLI args) for `real_main` and integration-test harnesses.
- `utils::which::SearchPath` is now public. Package detection no longer reads `PATH` from the process env; it goes through a `SearchPath` threaded via `HostInputs`, so library consumers and test harnesses can control which binaries detection sees.

## [0.18.0] - 2026-05-20

### Added
- Fedora 42 + DNF5 host support: `Os::current()` parses `/etc/os-release` and recognizes Fedora; `[install_hint.dnf5]` ships as a required field on every pkg, with install commands of the form `sudo dnf5 install <pkgs>` (user-facing label `dnf`).
- Ubuntu 24.04 + apt host support, with `[install_hint.apt]` as a required field and `sudo apt install <pkgs>` install commands.
- Arch Linux + pacman host support (rolling release, no version payload), with `[install_hint.pacman]` as a required field and `sudo pacman -S <pkgs>` install commands.
- `cargo install` as a supplementary package manager. Surfaces alongside the primary manager (brew/dnf5/apt/pacman) for Rust tools that aren't in the primary's repos. `[install_hint.cargo]` is a required field.
- `zenops doctor` now reports the detected distro and version (`os: fedora 42`, `os: ubuntu 24.04`, `os: arch`, `os: macos`) instead of the bare `std::env::consts::OS`. On unsupported hosts it falls back to the raw label with the detection error in the hint column.
- `condition.os` matchers now accept hierarchical patterns: `"linux"`, `"macos"`, `"fedora"`, `"fedora-42"`, `"ubuntu"`, `"ubuntu-24.04"`, `"arch"`. Distro tokens match any version of that distro; `"linux"` matches any supported Linux distro.
- New `os` module exposing the strict, exhaustive `Os` / `OsPattern` types backing host detection and condition matching. Adding a new distro or version is a deliberate code change paired with an integration-test row — no silent fallback.
- Docker-driven cross-platform integration test harness (`tests/cross-platform/`) covering Fedora 42, Ubuntu 24.04, and Arch Linux against both bash and zsh. Wired into `scripts/prerelease.sh` as a pre-release gate.

### Changed
- **Breaking (config schema):** every `[pkg.<name>.install_hint]` block must now declare entries for all five managers (`brew`, `dnf5`, `apt`, `pacman`, `cargo`). Use `packages = []` to signal that a pkg has no install path under a given manager. All twelve bundled defaults have been updated accordingly.
- Unsupported hosts now fail loudly with `UnsupportedPlatform` (non-Linux/macOS) or `UnsupportedHost` (Linux distro/version outside the matrix), instead of silently treating the host as generic Linux.
- `pkg_manager::detect` is split into `detect` (primary, returns one manager) and `detect_supplementary` (returns `Vec<DetectedPackageManager>`); cargo lives in the supplementary slot. The pkg-listing footer now reads `Install eligible via <manager>:` once per detected manager with eligible missing pkgs, instead of a single "all missing" footer.
- Bumped `clap_complete` 4.6.3 → 4.6.5, `gethostname` 1.0.2 → 1.1.0, `regex` 1.11.3 → 1.12.3, and transitive dependencies.

### Fixed
- `zenops --version` now prints the version string instead of failing with a clap parse error.
- `zenops apply` no longer fails with `missing field environment in shell` immediately after `zenops init` — the `[shell] environment` and `[shell] alias` fields now default to empty maps.

## [0.17.0] - 2026-05-13

### Added
- `zenops import` accepts a nested file under `~/.config/<pkg>/` (e.g. `~/.config/some-app/dir/file.json`) when no `[pkg.some-app]` exists yet: it creates the pkg, imports only that one file, and leaves the rest of the directory alone. Previously the strict layout check refused anything deeper than `~/.config/<pkg>` for a new pkg.
- New `ImportError::NestedDirectoryNotSupported` variant. A nested *directory* (rather than a file) for a brand-new pkg is still refused, with a diagnostic pointing the user at a single file or the parent pkg directory.
- The crate-level `error` module now re-exports every error type wrapped by `zenops::error::Error`. Consumers can import `ConfigError`, `ConfigFilesError`, `ConfigShellError`, `GitError`, `ImportError`, `InitError`, `OutputError`, `PkgError`, `PromptError`, `SchemaError`, `SshError`, and `WhichError` from a single `zenops::error::*` namespace. The original per-module paths (e.g. `zenops::prompt::PromptError`) still work.

### Changed
- **Breaking (library):** `zenops::ConfigError` is no longer re-exported at the crate root. Update callers to `zenops::error::ConfigError`.

## [0.16.0] - 2026-05-13

### Added
- New `when` detect strategy: gates a `detect` subtree on a host-level condition (a `[conditions]` name or an inline condition table) paired with a `then` child. A failing `when` evaluates the node as `false`, not "skip", so an `all` whose children are all gated to a different host evaluates `false` rather than empty-set-vacuously `true`. Lets a single pkg express OS-divergent detect paths without splitting into two `[pkg.*]` entries:
  ```toml
  [pkg.brew.detect]
  any = [
    { when = "macos", then = { exists = "/opt/homebrew/bin/brew" } },
    { when = "linux", then = { exists = "/home/linuxbrew/.linuxbrew/bin/brew" } },
  ]
  ```

### Changed
- **Breaking (config schema):** `[pkg.*.detect]` now identifies its kind by which key is present in the table, rather than a `type = "..."` discriminator. `type = "file"` / `path = "..."` becomes `exists = "..."`; `type = "which"` / `binary = "..."` becomes `which = "..."`; `type = "any"` / `of = [...]` becomes `any = [...]`; same for `all`. The new `when` strategy uses `when` + `then`. Each `[detect]` table must have exactly one of `exists`, `which`, `any`, `all`, or `when` (paired with `then`); mixing kinds or using the legacy `type = "..."` form fails to load with a diagnostic that names the offender.

## [0.15.0] - 2026-05-11

### Added
- New `[conditions]` table for declaring named, composable host gates. Each condition is one of seven kinds — `os`, `shell`, `hostname`, `file_exists`, `all`, `any`, `not` — written as a single-key TOML table. Built-ins (`linux`, `macos`, `bash`, `zsh`) ship with the binary and can be overridden by a user entry with the same name. References are validated and cycle-checked at load time; hostname regexes compile at load too, so a malformed pattern fails loudly.
- New `pkg.*.when` field: gate any pkg on a named condition (string) or an inline condition table.
- `zenops import` can now extend an existing managed config by passing a single file deeper inside an already-managed directory (e.g. `~/.config/helix/templates/new.toml`). It appends to the existing entry's `symlinks` array and runs the same move+symlink flow as a new-pkg import. The new-pkg-only flags (`--pkg`, `--source`, `--brew`, `--no-install-hint`) are rejected in this mode.
- `zenops import` reconciles an already-managed root when re-run on it: new files on disk are added to the entry's `symlinks` array, and paths whose home-side counterpart is gone are dropped (along with the repo copy). A fully in-sync reconcile skips the confirmation prompt and the `config.toml` reflow.
- `zenops import` reconcile now detects renamed symlinks: when a managed symlink is renamed under `$HOME`, reconcile moves the repo-side file to match and retargets the symlink, instead of classifying it as a deletion plus a new symlink-elsewhere.

### Changed
- **Breaking (config schema):** `pkg.*.supported_os`, `pkg.*.supported_shells`, and `pkg.*.detect.os` are removed. Replace them with `pkg.*.when` referencing a named condition (or an inline combinator). Leftover fields surface as `unknown field 'supported_os'` / `'supported_shells'` / `'os'` (inside `[pkg.<x>.detect]`) at load time.
- `zenops import` refuses path shapes that previously produced confusing errors: the zenops repo dir itself, `.config/` as the input, a regular file at `.config/<x>`, `--source` overrides that escape the repo, pkg-key collisions, empty `--brew`, and a missing zenops repo are now rejected with explicit messages. Source dirs skip `.git` / `.hg` / `.svn` so a config-as-checkout doesn't drag pack files into the repo.

## [0.14.0] - 2026-05-07

### Added
- `zenops import` subcommand: take an existing on-disk `~/.config/<x>/` or `~/.<x>` config under zenops management. Copies the files into `configs/<key>/`, replaces the originals with symlinks, and appends a `[[pkg.<key>.configs]]` block to `config.toml`. Shows a typed plan and prompts for confirmation before mutating; supports `--pkg`, `--source`, `--brew`, `--no-install-hint`, `--yes`, and `--dry-run`.
- New public modules `crate::import` (with `ImportError`), `crate::prompt` (with `PromptError`), and `crate::schema` (with `SchemaError`); plus new public types `crate::config::ConfigError`, `crate::config::shell::ConfigShellError`, and `crate::git::GitError`. Each owns the failure modes previously held as flat variants on `Error`.

### Changed
- **Breaking (library):** Continued the per-module error split. The variants `OpenDb`, `ParseDb`, `UnresolvedInput`, `TemplateUnterminated`, `PromptRead`, `PromptInterrupted`, `SchemaEmit`, `SchemaWrite`, and `BrewProbeFailed` have been removed from the crate-level `Error` enum and folded into wrapper variants `Error::Config(ConfigError)`, `Error::ConfigShell(ConfigShellError)`, `Error::Prompt(PromptError)`, and `Error::Schema(SchemaError)`. Callers matching on the old variants must match on the new wrapper or its inner enum.

### Fixed
- Malformed `git status --porcelain` output is now surfaced as a typed `Error::Git(GitError::PorcelainParse)` with the offending line and reason, instead of being silently misinterpreted as a clean entry.

## [0.13.0] - 2026-05-05

### Changed
- **Breaking (library):** Restructured the crate-level `Error` enum. The flat per-source variants (`FailedToWriteConfig`, `FailedToReadConfig`, `SymlinkProbeFailed`, `CreateSymlinkFailed`, `SymlinkRealPathMissing`, `RefusingToOverwriteOtherWithSymlink`, `RefusingToOverwriteFileWithSymlink`, `RefusingToOverwriteDirectoryWithSymlink`, `CreateDirectoryError`, `InitDirNotEmpty`, `InitDirExists`, `InitGitDirExists`, `InitGitInitFailed`, `InitNeedsTty`, `InitNoConfigToml`, `InitCloneFailed`, `InitIo`, `CurlNotFound`, `GithubKeyFetchFailed`, `GithubKeyParseFailed`) have been folded into per-module wrapper variants `Error::ConfigFiles(ConfigFilesError)`, `Error::Init(InitError)`, `Error::Ssh(SshError)`, `Error::PkgError(pkg::Error)`, and `Error::Which(which::Error)`. Callers matching on the old variants must match on the new wrapper or its inner enum.
- **Breaking (library):** `crate::init` is now a public module exporting `InitError`; the `config_files` module gained a public `ConfigFilesError` and `config::ssh` gained a public `SshError`. These are the inner types referenced by the new `Error` wrapper variants.

### Added
- New `Error::NoHomeDir` variant: bubbles out of `main` when `home::home_dir()` returns `None`, replacing the previous panic.
- New `Error::BrewProbeFailed(PathBuf, io::Error)` variant: surfaces IO failures while probing for a `brew` install prefix instead of treating them as "not installed".

### Fixed
- Filesystem probes now use `Path::try_exists` and propagate IO errors, so unreadable parents or broken metadata produce actionable errors rather than misleading "not found" results.
- `xshell` failures from git probes are no longer swallowed; failed `git`/`Shell::new` invocations now propagate instead of being silently treated as a clean working tree.
- Replaced remaining `.unwrap()` calls on `Shell::new()` and `home::home_dir()` in command dispatch with proper error propagation, so misconfigured environments report a clean error instead of panicking.
- Extra error coverage in `config::pkg` (detect/doctor paths) so previously-swallowed failures now reach the user.

## [0.12.0] - 2026-04-29

### Changed
- **Breaking (library):** Collapsed the `Output` trait to a single `push(Event)` method. Per-event push methods are gone; renderers now match on the new `Event` enum. The event types (`Status`, `AppliedAction`, `PkgEntry`, `DoctorCheck`, `InitSummary`, `BootstrapSummary`, etc.) re-export from a new `output::event` module.
- Internal `todo!()` and `unreachable!()` panics in the config-files and git paths are now typed `Error` variants, so previously-panicking edge cases (FIFOs/sockets at managed paths, missing parents, broken symlinks) surface as actionable error messages.

### Added
- New `Error` variants: `FailedToReadConfig`, `SymlinkProbeFailed`, `CreateSymlinkFailed`, `SymlinkRealPathMissing`, `RefusingToOverwriteOtherWithSymlink` — covering the previously-panicking filesystem edge cases.
- `Args` gained a `stdin_is_terminal` field (captured once in `main`) so subcommands receive the TTY signal instead of probing global process state.

### Fixed
- `git status` porcelain v2 parser miscounted some entries; rewritten with full coverage.

## [0.11.0] - 2026-04-28

### Added
- New `zenops init` bootstrap form (no URL): prompts for shell, name, and email, writes a minimal `config.toml` at `~/.config/zenops`, runs `git init`, and makes the initial commit. Refuses to run if `~/.config/zenops` already exists. Companion to the existing `zenops init <url>` clone form.
- New `BootstrapSummary` JSON event reporting the fresh repo's path and the chosen identity.
- New public `line_prompter` module exposing `LinePrompter`, `RustylinePrompter`, and `BufReadPrompter` for callers wiring up their own line-edited prompts.

### Changed
- Interactive prompts (apply confirmation, commit message, init bootstrap) now read through rustyline, giving Home/End, arrow keys, history, and proper UTF-8 line editing.
- **Breaking (library):** `Cmd::Init { url }` is now `Option<String>`. Pass `None` to select the bootstrap form; `--branch` is rejected without a URL.
- **Breaking (library):** `TerminalPrompter::new` now returns `Result<Self, Error>` because rustyline can fail to open the controlling terminal.

## [0.10.0] - 2026-04-27

### Added
- `zenops pkg [PATTERN]...` accepts positional patterns to narrow the listing to packages whose display name or map key matches any of the substrings (case-insensitive, multi-pattern OR). The aggregate-install footer follows the visible set.

### Changed
- **Breaking:** structured and human output (`zenops status`, `zenops pkg`, `-o json`, etc.) now goes to stdout instead of stderr, matching the `find . -name hello` convention. Log messages and fatal errors continue to go to stderr. Pipelines that previously read stderr to capture output need to read stdout (or merge with `2>&1`).

## [0.9.0] - 2026-04-24

### Added
- `zenops doctor` subcommand that diagnoses the environment.
- `zenops schema` subcommand that dumps the auto-generated JSON Schema bundle for the config file, so editors and validators can type-check `config.toml` without running zenops.
- `-o json` output format is now supported by `pkg`, `doctor`, and `init` (previously only a subset of commands).
- Split config reference documentation into `docs/`; `README.md` is now focused on onboarding.

### Changed
- **Breaking:** `zenops apply --yes` now aborts with `DirtyRepoRequiresAllowDirty` when the zenops config repo has uncommitted changes. Pass `--allow-dirty` to opt back in. The interactive prompt and `--dry-run` paths are unchanged. This makes CI and cron jobs fail loudly on divergence instead of applying silently from a dirty checkout.

## [0.8.3] - 2026-04-23

### Added
- `[user]` config section with `name` and `email`, also exposed as `${user.name}` and `${user.email}` template variables for any `${...}`-expanded config value.
- `[git]` section that manages `~/.gitconfig` on every `zenops apply` — writes `user.name`/`user.email`, and enables commit signing via `[git.signing]` with `type = "ssh"` (key path) or `type = "gpg"` (key ID/fingerprint).
- `[[ssh.allowed_signers]]` entries that generate `~/.ssh/allowed_signers` for verifying SSH-signed git commits. Supports `type = "github"` (fetches signing keys from `/users/<username>/ssh_signing_keys` via `curl`) and `type = "manual"`. With `[git.signing] type = "ssh"` configured, `gpg.ssh.allowedSignersFile` is pointed at the generated file automatically.

## [0.8.2] - 2026-04-23

### Added
- `zenops init <url>` subcommand clones an existing zenops config repo into `~/.config/zenops`, validates that it has a `config.toml`, and prints a short summary — so bootstrapping a fresh machine no longer requires crafting the config directory by hand.
- `--branch`/`-b` flag on `zenops init` to check out a specific branch or tag instead of the remote's default HEAD.
- `--apply` flag on `zenops init` to chain straight into `zenops apply` after a successful clone, with `--yes`/`-y` to skip prompts non-interactively.

## [0.8.1] - 2026-04-22

### Changed
- Publish `repository` metadata so crates.io links back to the GitHub repo.

## [0.8.0] - 2026-04-22

### Added
- `zenops status --all` / `-a` lists clean items too, so you can confirm zenops walked everything it manages instead of relying on silence.
- JSON output gains a `git_repo_clean` event kind.

### Changed
- Status output color scheme: green is now reserved for "no changes needed" states (`✓ ok`, `✓ clean`) and successful post-apply outcomes. Pending changes that were previously green (`+ missing`, `A added`) are now yellow, matching the existing `~ modified` convention.
- Symlink rows render with tiered styling: the shared zenops prefix uses an extra-dim style, the ` → ` arrow is bold, and the distinguishing tail renders at default weight. The same prefix split applies to any zenops-rooted path, including git rows.

### Removed
- **Breaking (JSON):** the `pkg_missing` event kind has been replaced by a unified `pkg` event carrying a nested `PkgStatus` enum. Consumers parsing zenops JSON output must update to the new shape.

## [0.7.0] - 2026-04-22

### Added
- `--output json` emits newline-delimited JSON events (one per line) to stderr for scripts and tooling.
- `zenops pkg` now prints a note when no supported package manager is detected on PATH (currently supported: brew).

### Changed
- Human output is redesigned with colored status markers, dimmed paths, and column-aligned rows.
- Color auto-detection now reflects whether stderr (where output is written) is a terminal, not stdout — fixing color suppression when stdout is piped.
- `ColorChoice::enabled` now requires a `stream_is_terminal: bool` argument so callers can resolve color for any specific stream.

### Removed
- `--output log` has been replaced by `--output human` (the new default). Scripts that parsed the old log-style output should migrate to `--output json`.

## [0.6.1] - 2026-04-22

### Changed
- Updated `zenops-expand` dependency to v0.5.0.

## [0.6.0] - 2026-04-21

### Added
- Explicit `any` and `all` combinators for `pkg.<name>.detect`, letting a pkg be considered installed when any / all of a set of sub-strategies matches.
- Optional `os = ["macos", "linux", ...]` gate on detection strategies — evaluates to false when the current OS isn't listed. Useful for GUI apps like Ghostty on macOS whose binary lives in `/Applications/*.app` and isn't always on PATH.

### Changed
- **Breaking:** `pkg.<name>.detect` is now a single detection strategy instead of an implicitly OR-ed list. Configs using `[[pkg.X.detect]]` or `detect = [...]` must migrate to `[pkg.X.detect]` or `detect = { ... }`; multiple signals must collapse to a single `which` or be wrapped in an explicit `type = "any"` / `type = "all"` with an `of = [...]` list.
- Simplified the built-in `sk` and `starship` detects to a single `which` lookup, removing hardcoded `~/.cargo/bin/…` and `/opt/homebrew/bin/…` fallback chains — if the binary is installed it belongs on PATH.

## [0.5.1] - 2026-04-21

### Added
- `apply` now detects uncommitted changes in the zenops config repo and offers a three-way prompt before proceeding: commit & push, continue without committing, or abort. `--yes` and `--dry-run` keep the warning but skip the prompt (and continue).

### Fixed
- `zenops status` on a config repo with deleted or added files no longer panics; the git porcelain parser now decodes the full XY status pair instead of just modified/untracked entries.

## [0.5.0] - 2026-04-21

### Added
- `zenops apply` and `zenops status` now warn when a pkg with `enable = "on"` is declared but not detected on the host, including a ready-to-run install command when a package manager (e.g. Homebrew) is available.

### Changed
- **Breaking: config schema.** Configs are now declared under the pkg that owns them as `[[pkg.<key>.configs]]`, replacing the old top-level `[[configs]]` array. The `name` field on a config is gone — the pkg key doubles as the config directory name, with an optional override. Configs and shell hooks only apply when the pkg is considered installed (`detect` strategies match, or no `detect` is specified).