feat(library): top-level Fnox::discover() / get / list convenience API#442
feat(library): top-level Fnox::discover() / get / list convenience API#442
Conversation
…umers Per discussion jdx#441 — the existing library API (Config + per-secret SecretConfig + secret_resolver::resolve_secret) is sufficient but CLI-shaped, so every consumer ends up replicating GetCommand::run's boilerplate to do basic "give me this secret" work. Adds a top-level Fnox struct in src/library.rs with: - Fnox::discover() — upward fnox.toml search from CWD - Fnox::discover_from(start) — same, from a specific path - Fnox::open(path) — explicit config path, no search - Fnox::with_profile(profile) — builder for non-default profile - Fnox::profile() — read active profile - Fnox::config() — borrow underlying Config for callers that need finer-grained access - Fnox::get(key) -> Option<String> — resolve a secret - Fnox::list() -> Vec<String> — declared secret names Re-exported as fnox::Fnox so consumers can write three lines: let fnox = Fnox::discover()?; let value = fnox.get(\"MY_KEY\").await?; Usage docs in module-level comment. set() is intentionally NOT in this PR. SetCommand::run is ~100 LOC of provider/encryption/remote-storage branching, base64 handling, and dry-run logic — substantial enough to warrant its own design pass. Leaving it for a follow-up PR rather than rushing it in here. Tests: 7 new in library::tests covering discover (adjacent + walk-up + missing), list (declaration order preserved), get (default value resolution + clear error on undeclared key), and profile selection. Full lib suite: 142 passed (135 existing + 7 new).
There was a problem hiding this comment.
Code Review
This pull request introduces a new library module and the Fnox struct, providing a high-level convenience API for downstream consumers to load configurations and resolve secrets. The feedback suggests several improvements: ensuring that configuration loading correctly handles hierarchical merging (global/parent configs), optimizing the Fnox struct's performance by wrapping the potentially expensive Config in an Arc, simplifying the discover method by leveraging existing Config logic, and refining the get method to use more efficient lookups and specific error types.
| loop { | ||
| let candidate = current.join(CONFIG_FILENAME); | ||
| if candidate.exists() { | ||
| let config = Config::load_smart(&candidate)?; |
There was a problem hiding this comment.
The current implementation of discover_from (and open) has a significant logic issue regarding configuration merging. Config::load_smart only performs a recursive merge if the path matches the default filename exactly (relative to CWD). For absolute paths found during the walk, it calls Config::load, which only loads that single file. This bypasses the hierarchical merging logic (global config, parent configs, local overrides), which is critical for resolving providers and inherited secrets. For example, if a provider is defined in ~/.config/fnox/config.toml, it will not be available here. This method should ideally leverage Config's internal recursive loading logic to ensure a fully merged configuration is returned.
| /// Cheap to clone (just a [`Config`] + a [`String`] profile). Hold | ||
| /// across `.await` freely. |
There was a problem hiding this comment.
| pub fn discover() -> Result<Self> { | ||
| let start = std::env::current_dir() | ||
| .map_err(|e| FnoxError::Config(format!("Failed to read current directory: {e}")))?; | ||
| Self::discover_from(&start) | ||
| } |
There was a problem hiding this comment.
This can be simplified by calling Self::open with the default config filename. Config::load_smart already handles the upward search from the current directory when given the default filename, ensuring consistency with the CLI's loading logic (including support for .fnox.toml and merging with global/local configs).
pub fn discover() -> Result<Self> {
Self::open(crate::config::DEFAULT_CONFIG_FILENAME)
}| let secrets = self.config.get_secrets(&self.profile)?; | ||
| let secret_config = secrets.get(key).ok_or_else(|| { | ||
| FnoxError::Config(format!( | ||
| "Secret '{key}' not declared in profile '{}'", | ||
| self.profile | ||
| )) | ||
| })?; |
There was a problem hiding this comment.
Using self.config.get_secret is more efficient as it avoids cloning the entire IndexMap of secrets for the profile. Additionally, returning FnoxError::SecretNotFound is preferred over the generic FnoxError::Config as it provides a more specific error type for library consumers to handle.
| let secrets = self.config.get_secrets(&self.profile)?; | |
| let secret_config = secrets.get(key).ok_or_else(|| { | |
| FnoxError::Config(format!( | |
| "Secret '{key}' not declared in profile '{}'", | |
| self.profile | |
| )) | |
| })?; | |
| let secret_config = self.config.get_secret(&self.profile, key).ok_or_else(|| { | |
| FnoxError::SecretNotFound { | |
| key: key.to_string(), | |
| profile: self.profile.clone(), | |
| config_path: None, | |
| suggestion: None, | |
| } | |
| })?; |
Greptile SummaryThis PR adds a Confidence Score: 4/5Safe to merge with minor follow-ups; no runtime bugs or security concerns in the changed paths Only P2 findings remain: CONFIG_FILENAME should be re-exported from the crate root to match its own doc comment, and the regression-guard test is environment-sensitive. Neither affects runtime behavior. No files require special attention beyond the two P2 findings noted above Important Files Changed
Sequence DiagramsequenceDiagram
participant C as Caller
participant F as Fnox
participant CS as Config::load_smart
participant CL as Config::load
participant SR as secret_resolver
participant SU as suggest
C->>F: Fnox::discover()
F->>CS: load_smart("fnox.toml")
CS->>CS: load_with_recursion (upward walk + merge)
CS-->>F: Arc<Config>
F-->>C: Fnox { config, profile }
C->>F: Fnox::open(path)
F->>CL: Config::load(resolved_path)
CL-->>F: Config
F-->>C: Fnox { config, profile }
C->>F: fnox.get("KEY")
F->>F: config.get_secret(profile, key)
alt key declared
F->>SR: resolve_secret(config, profile, key, secret_config)
SR-->>F: Option<String>
F-->>C: Ok(Option<String>)
else key undeclared
F->>F: self.list().ok()
F->>SU: find_similar(key, declared_names)
SU-->>F: suggestion
F-->>C: Err(SecretNotFound { key, profile, suggestion })
end
C->>F: fnox.list()
F->>F: config.get_secrets(profile)
F-->>C: Ok(Vec<String>)
Reviews (6): Last reviewed commit: "fix(library): open() bypasses load_smart..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
Adds a new top-level Fnox convenience API intended for downstream library consumers to discover/load config once and then get/list secrets without duplicating CLI-oriented boilerplate.
Changes:
- Introduce
src/library.rswithFnox::{discover, discover_from, open, with_profile, get, list}plus tests. - Export the new module and re-export
Fnoxfromsrc/lib.rs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/library.rs |
New Fnox wrapper type + discovery/loading helpers, get/list APIs, and unit tests. |
src/lib.rs |
Exposes the new library module and re-exports Fnox at the crate root. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let mut current: PathBuf = start.as_ref().to_path_buf(); | ||
| loop { | ||
| let candidate = current.join(CONFIG_FILENAME); | ||
| if candidate.exists() { | ||
| let config = Config::load_smart(&candidate)?; | ||
| return Ok(Self { | ||
| config, | ||
| profile: DEFAULT_PROFILE.to_string(), | ||
| }); |
There was a problem hiding this comment.
discover_from finds an absolute config path and then calls Config::load_smart(&candidate). Because load_smart only enables recursive upward search/merging when the path equals a default filename (e.g. "fnox.toml"), passing an absolute path forces the non-recursive Config::load code path. This diverges from the docs here ("same path the binary takes") and from CLI behavior (global + parent + .fnox/profile/local merges, plus project_dir being set). Consider either (a) exposing a Config::load_with_recursion_from(start_dir)-style API and using that here, or (b) updating the docs/PR description to clarify that this loads only the single nearest fnox.toml without recursion/merging.
| let mut current: PathBuf = start.as_ref().to_path_buf(); | ||
| loop { | ||
| let candidate = current.join(CONFIG_FILENAME); | ||
| if candidate.exists() { |
There was a problem hiding this comment.
discover_from uses candidate.exists() to decide whether it found a config file. exists() will also be true for directories named fnox.toml, which then causes Config::load_smart to attempt to read a directory as a file and return a confusing error. Using candidate.is_file() (or metadata().is_file()) would make the failure mode clearer and avoid false positives.
| if candidate.exists() { | |
| if candidate.is_file() { |
| /// Open an explicit config path. Skips the upward search. | ||
| pub fn open(config_path: impl AsRef<Path>) -> Result<Self> { | ||
| let config = Config::load_smart(config_path)?; | ||
| Ok(Self { | ||
| config, | ||
| profile: DEFAULT_PROFILE.to_string(), | ||
| }) |
There was a problem hiding this comment.
open() is documented as "Open an explicit config path. Skips the upward search", but it currently calls Config::load_smart(config_path). If a caller passes a default filename like "fnox.toml" (or .fnox.toml, fnox.local.toml, etc.), load_smart will enable recursive upward search/merging instead of performing a direct load. To make open() reliably non-searching, consider resolving config_path to an absolute path (if needed) and calling Config::load(...) directly.
| /// Default profile when callers don't specify one. Matches the | ||
| /// binary's default (no `--profile` flag → `"default"`). | ||
| pub const DEFAULT_PROFILE: &str = "default"; | ||
|
|
There was a problem hiding this comment.
The library client hard-codes the default profile to "default" (DEFAULT_PROFILE) when constructing Fnox instances. In the rest of the codebase, "default" is only the fallback after considering CLI flags and environment (e.g. Config::get_profile(...) / Settings::get().profile). With the current implementation, FNOX_PROFILE (and any future default-profile selection logic) will be ignored unless callers remember to call with_profile(...), which is a behavioral mismatch with the CLI and the comment above this const. Consider initializing profile from crate::settings::Settings::get().profile (or Config::get_profile(None)) instead of a hard-coded constant.
| /// Default profile when callers don't specify one. Matches the | |
| /// binary's default (no `--profile` flag → `"default"`). | |
| pub const DEFAULT_PROFILE: &str = "default"; | |
| /// Final fallback profile when no explicit or configured profile is | |
| /// available. | |
| pub const DEFAULT_PROFILE: &str = "default"; | |
| /// Resolve the initial profile for library clients using the same | |
| /// settings-based default selection as the CLI. | |
| fn initial_profile() -> String { | |
| crate::settings::Settings::get().profile.clone() | |
| } |
| let secrets = self.config.get_secrets(&self.profile)?; | ||
| let secret_config = secrets.get(key).ok_or_else(|| { | ||
| FnoxError::Config(format!( | ||
| "Secret '{key}' not declared in profile '{}'", | ||
| self.profile | ||
| )) | ||
| })?; |
There was a problem hiding this comment.
get() calls self.config.get_secrets(&self.profile)?, which clones the full secrets IndexMap on every lookup. The codebase already has Config::get_secret(profile, key) specifically to avoid this clone for single-key lookups. Switching get() to use get_secret would reduce allocations and make repeated get() calls significantly cheaper.
| let secrets = self.config.get_secrets(&self.profile)?; | |
| let secret_config = secrets.get(key).ok_or_else(|| { | |
| FnoxError::Config(format!( | |
| "Secret '{key}' not declared in profile '{}'", | |
| self.profile | |
| )) | |
| })?; | |
| let secret_config = self.config.get_secret(&self.profile, key)?; |
| let secret_config = secrets.get(key).ok_or_else(|| { | ||
| FnoxError::Config(format!( | ||
| "Secret '{key}' not declared in profile '{}'", | ||
| self.profile | ||
| )) | ||
| })?; |
There was a problem hiding this comment.
When the requested key is not declared, get() returns FnoxError::Config(...). Elsewhere (e.g. GetCommand::run) this condition is represented as FnoxError::SecretNotFound { .. }, which includes richer diagnostic info (config_path, suggestions, actionable help). For consistency and more useful errors to downstream callers, consider using FnoxError::SecretNotFound here as well.
Convergent findings across all three reviewers, fixed together:
HIGH (Gemini, Greptile, Copilot):
discover_from() built an absolute candidate path then handed it to
Config::load_smart, bypassing the upward-search/parent-merge/local-
override/global-config path that load_smart only takes for the
default filename. Per AGENTS.md "Loading order" this dropped 4 of
the 5 documented merge layers.
Fix: discover() now calls Config::load_smart(CONFIG_FILENAME) with
no directory prefix, restoring the full merge chain. Removed
discover_from() — couldn't be made safe without process-global
std::env::set_current_dir, which violates AGENTS.md and is unsafe
in parallel test runs anyway.
MED (Greptile): list() was async for no reason. Made it sync —
Config::get_secrets is fully synchronous.
MED (Gemini): Fnox: Clone deep-copied the entire Config (multiple
IndexMap fields). Wrapped in Arc<Config>; new test asserts clones
share the Arc rather than copying.
MED (Gemini, Copilot): get() called config.get_secrets(profile)
which clones the whole IndexMap on every lookup. Switched to
config.get_secret(profile, key) which returns Option<&SecretConfig>.
MED (Copilot): hard-coded "default" profile bypassed
Config::get_profile's CLI/env resolution chain. Now defers to
Config::get_profile(None) which honors FNOX_PROFILE — matches
binary semantics.
LOW (Copilot): missing-key error was FnoxError::Config(...). Now
returns FnoxError::SecretNotFound { key, profile, config_path,
suggestion } matching what GetCommand::run produces, so downstream
consumers can pattern-match without a wrapper-specific variant.
LOW (Gemini): discover() collapsed to a 4-line implementation that
delegates to Config::load_smart.
Tests: dropped discover_from-specific tests, added open() positive +
negative cases, kept get/list/profile coverage, added clone-sharing
assertion. 7 tests in library:: still passing; full lib suite at
142 passed (no regressions).
|
Thanks @gemini-code-assist @greptile-apps @copilot — all findings addressed in 79f1a81 (just pushed): HIGH (Gemini, Greptile, Copilot) — MED (Greptile) — MED (Gemini) — MED (Gemini, Copilot) — MED (Copilot) — Hard-coded LOW (Copilot) — Missing-key error is now LOW (Gemini) — Full lib suite: 142 passed, 0 failed. Ready for re-review. |
There was a problem hiding this comment.
Code Review
This pull request introduces a new high-level Fnox API in src/library.rs to simplify secret resolution for library consumers, providing methods for discovery, configuration loading, and secret retrieval. The review feedback identifies a missing discover_from method mentioned in the PR description and suggests improving the documentation for the get method's return values. Additionally, there is a recommendation to enhance the SecretNotFound error by providing fuzzy-matched suggestions for similar keys, consistent with the CLI's existing behavior.
| /// the `FNOX_PROFILE` env var (matches binary semantics). | ||
| /// | ||
| /// Returns [`FnoxError`] if loading/parsing fails. | ||
| pub fn discover() -> Result<Self> { |
There was a problem hiding this comment.
The pull request description mentions the addition of Fnox::discover_from(start), but this method is missing from the implementation. Additionally, the description mentions three tests for this method which are also absent from the patch. Given that Config::load_with_recursion currently ignores its input path and always uses the current working directory, implementing this correctly might require fixing the underlying Config logic first.
| /// Resolve a secret by name. Returns the resolved value, or | ||
| /// `None` if the key is declared with `if_missing = "ignore"` and | ||
| /// has no value. |
There was a problem hiding this comment.
The docstring is slightly incomplete regarding the None return case. According to handle_missing_secret in secret_resolver.rs, Ok(None) is returned when if_missing is set to either "ignore" or "warn".
| /// Resolve a secret by name. Returns the resolved value, or | |
| /// `None` if the key is declared with `if_missing = "ignore"` and | |
| /// has no value. | |
| /// Resolve a secret by name. Returns the resolved value, or | |
| /// `None` if the key is declared with `if_missing = "ignore"` or | |
| /// `"warn"` and has no value. |
| let secret_config = self.config.get_secret(&self.profile, key).ok_or_else(|| { | ||
| FnoxError::SecretNotFound { | ||
| key: key.to_string(), | ||
| profile: self.profile.clone(), | ||
| config_path: self.config.secret_sources.get(key).cloned(), | ||
| suggestion: None, | ||
| } | ||
| })?; |
There was a problem hiding this comment.
The SecretNotFound error can be made more helpful by providing suggestions for similar keys, matching the user-friendly behavior of the CLI. You can leverage the existing list() method and the crate::suggest module to populate the suggestion field.
let secret_config = self.config.get_secret(&self.profile, key).ok_or_else(|| {
let suggestion = self.list().ok().and_then(|names| {
let similar = crate::suggest::find_similar(key, names.iter().map(|s| s.as_str()).collect());
crate::suggest::format_suggestions(&similar)
});
FnoxError::SecretNotFound {
key: key.to_string(),
profile: self.profile.clone(),
config_path: self.config.secret_sources.get(key).cloned(),
suggestion,
}
})?;Three findings on v2 of the convenience-API PR, addressed together: P1 (greptile, src/library.rs:29): Module-level doc example called fnox.list() and dropped the Result. After list() became sync, copy-pasting the snippet wouldn't compile. Fixed: `let names = fnox.list()?;` MED (gemini, src/library.rs:115): Docstring said "None when if_missing = ignore" but per handle_missing_secret the None case covers BOTH "ignore" and "warn". Tightened the wording. MED (gemini, src/library.rs:131): SecretNotFound's `suggestion` field was always None. GetCommand::run populates it via crate::suggest::find_similar so the user gets a "Did you mean DATABASE_URL?" hint instead of opaque "not declared". Now mirrors the binary: list() the active profile's keys, run them through find_similar + format_suggestions, attach to the error. New test get_secret_not_found_carries_did_you_mean_suggestion locks the behavior in: declares DATABASE_URL/DATABASE_TOKEN/NPM_TOKEN, asks for "DATABASE_UR" (typo), asserts suggestion contains "DATABASE_URL". Reused the renamed get() body to use early-return instead of ok_or_else so the suggestion call site reads top-to-bottom. Tests: 8 in library:: (was 7), 143 in full lib suite (was 142). Note on gemini's HIGH at :64 (PR description still mentions discover_from): that finding refers to my PR body text, not source — discover_from was actually removed in 79f1a81 per v1 feedback. Will update the PR description in a separate edit so the body matches the final code shape.
|
v3 (c17f24a) addresses the second round of feedback:
Lib tests: 143 passed (was 142). Ready for re-review. |
P1 finding from greptile on PR jdx#442 v3: Config::load_smart triggers load_with_recursion (upward search + parent merge + local override + global config) whenever the path argument equals one of the bare default filenames per all_config_filenames(None). CONFIG_FILENAME is "fnox.toml", which IS one of those. So `Fnox::open(CONFIG_FILENAME)` silently became identical to `Fnox::discover()` — even though the docstring promised "explicit file path, no merging". The exposed CONFIG_FILENAME constant made `Fnox::open(fnox::CONFIG_FILENAME)` the natural idiom, which silently did discovery. Real footgun. Fix per greptile's recommendation: call Config::load directly (not load_smart) in open(). Resolve relative paths against CWD first to keep open(rel) and open(abs) consistent. Updated docstring to be explicit: "strictly load this one file — no upward-search, no parent-merge, no global-config layer". New regression test: open_with_bare_default_filename_does_not_silently_discover It calls Fnox::open(CONFIG_FILENAME) and asserts Err — locks the contract that open and discover are distinct paths. Test prints a warning and skips assertion if it actually finds a fnox.toml in CWD (test environment masking the regression we're guarding). Lib suite: 144 passed (was 143).
|
v4 (b9f94f8) addresses greptile's P1:
Fix: call Regression test: Lib suite: 144 passed (was 143). Ready for re-review. |
### 🚀 Features - **(library)** top-level Fnox::discover() / get / list convenience API by [@bglusman](https://github.com/bglusman) in [#442](#442) ### 🐛 Bug Fixes - **(docs)** stack banner and pin close button on mobile by [@jdx](https://github.com/jdx) in [#437](#437) - **(set)** fall back to current provider when updating secrets by [@rpendleton](https://github.com/rpendleton) in [#439](#439) ### 📚 Documentation - **(site)** show release version and github stars by [@jdx](https://github.com/jdx) in [#443](#443) - add cross-site announcement banner by [@jdx](https://github.com/jdx) in [#434](#434) - respect banner expires field by [@jdx](https://github.com/jdx) in [#436](#436) ### 🛡️ Security - **(build)** deterministic provider ordering in generated schema by [@jdx](https://github.com/jdx) in [#432](#432) ### 🔍 Other Changes - **(release)** append en.dev sponsor blurb to release notes by [@jdx](https://github.com/jdx) in [#431](#431) ### 📦️ Dependency Updates - bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in [#435](#435) - bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in [#438](#438) ### New Contributors - @bglusman made their first contribution in [#442](#442)
### 🚀 Features - **(library)** top-level Fnox::discover() / get / list convenience API by [@bglusman](https://github.com/bglusman) in [#442](#442) ### 🐛 Bug Fixes - **(docs)** stack banner and pin close button on mobile by [@jdx](https://github.com/jdx) in [#437](#437) - **(set)** fall back to current provider when updating secrets by [@rpendleton](https://github.com/rpendleton) in [#439](#439) ### 📚 Documentation - **(site)** show release version and github stars by [@jdx](https://github.com/jdx) in [#443](#443) - add cross-site announcement banner by [@jdx](https://github.com/jdx) in [#434](#434) - respect banner expires field by [@jdx](https://github.com/jdx) in [#436](#436) ### 🛡️ Security - **(build)** deterministic provider ordering in generated schema by [@jdx](https://github.com/jdx) in [#432](#432) ### 🔍 Other Changes - **(release)** append en.dev sponsor blurb to release notes by [@jdx](https://github.com/jdx) in [#431](#431) ### 📦️ Dependency Updates - bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in [#435](#435) - bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in [#438](#438) - bump communique to 1.1.2 by [@jdx](https://github.com/jdx) in [#444](#444) ### New Contributors - @bglusman made their first contribution in [#442](#442)
## Upstream release Bumps bundled fnox binary from 1.20.0 to 1.22.0. **Release**: https://github.com/jdx/fnox/releases/tag/v1.22.0 ## Release notes v1.22.0 introduces a top-level library API for embedding fnox in Rust applications, and fixes a sharp edge in `fnox set` that could turn an encrypted secret into plaintext. ## Added **Top-level `Fnox` library API** ([#442](jdx/fnox#442)) -- @bglusman Downstream Rust consumers can now use fnox as a library in three lines instead of replicating the internals of `GetCommand::run`: ```rust use fnox::Fnox; let fnox = Fnox::discover()?; // walks up + merges parent + local + global config let value = fnox.get("MY_KEY").await?; let names = fnox.list()?; ``` The new `Fnox` type lives in `src/library.rs` and is re-exported from the crate root. Highlights: - `Fnox::discover()` mirrors the binary's full config-discovery and merge chain via `Config::load_smart`, including the `FNOX_PROFILE` env var. - `Fnox::open(path)` loads an explicit config without the upward-search/merge behavior. - `Fnox::with_profile("staging")` builder for non-default profiles. - `get()` returns `FnoxError::SecretNotFound` with a populated "Did you mean…" suggestion, matching the CLI's UX so callers don't need to recompute it. - `Fnox` is cheap to clone (`Config` is held behind an `Arc`) and safe to hold across `.await`. `set()` is intentionally not part of this first cut; it'll get its own design pass. ## Fixed **`fnox set` no longer silently downgrades encrypted secrets to plaintext** ([#439](jdx/fnox#439)) -- @rpendleton When multiple providers were configured without a `default_provider`, running `fnox set` on an existing secret without `--provider` would write the new value as plaintext while leaving the original `provider = "..."` key in place. The next `fnox get` then failed trying to "decrypt" a value that was no longer encrypted. `fnox set` now reuses the secret's existing provider before falling back to `default_provider` or plaintext, so updates stay encrypted and readable without having to pass `--provider` on every call: ```bash fnox set --provider age MY_SECRET "original-value" # encrypted with age fnox set MY_SECRET "new-value" # still encrypted with age ``` **Deterministic provider ordering in the generated schema** ([#432](jdx/fnox#432)) -- @jdx Within-category provider ordering in `build/generate_providers.rs` was inheriting `fs::read_dir` order, which is OS- and filesystem-dependent. That non-determinism flowed into `docs/public/schema.json` and caused autofix.ci to keep reshuffling 100+ lines between runs. A secondary sort by provider name fixes the churn; running `fnox schema` twice now produces byte-identical output. **Mobile docs banner layout** ([#437](jdx/fnox#437)) -- @jdx At `<=640px` the announcement banner now switches to a column layout with the close button pinned to the top-right corner, instead of cramming the message and "Read more" link onto one squeezed line. ## Changed - Docs site nav now shows the current release version (read from `Cargo.toml` at build time) and a GitHub star count, matching the mise/aube docs ([#443](jdx/fnox#443)) -- @jdx - Added a dismissible cross-site announcement banner that fetches its config from `jdx.dev/banner.json` and respects the `expires` field ([#434](jdx/fnox#434), [#436](jdx/fnox#436)) -- @jdx ## New Contributors * @bglusman made their first contribution in [#442](jdx/fnox#442) **Full Changelog**: jdx/fnox@v1.21.0...v1.22.0 ## 💚 Sponsor fnox fnox is maintained by [@jdx](https://github.com/jdx) under [**en.dev**](https://en.dev) — a small independent studio building developer tooling like [mise](https://mise.jdx.dev/), [aube](https://aube.en.dev/), hk, and more. Keeping fnox secure, maintained, and free is funded by sponsors. If fnox is handling secrets or config for you or your team, please consider [sponsoring at en.dev](https://en.dev). Sponsorships are what let fnox stay independent and the project keep moving. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
jdx#442) Per [discussion jdx#441](jdx#441) — "sounds fine" 👍 ## What Adds a top-level `Fnox` struct in a new `src/library.rs` module so downstream library consumers can write three lines instead of replicating `GetCommand::run`'s boilerplate. ```rust use fnox::Fnox; let fnox = Fnox::discover()?; // walks up + merges parent + local + global let value = fnox.get("MY_KEY").await?; let names = fnox.list()?; ``` ## Final API surface (after v3 fixes) - `Fnox::discover()` — `Config::load_smart(CONFIG_FILENAME)` (full upward-search + parent-merge + local-override + global merge chain) - `Fnox::open(path)` — explicit config path - `Fnox::with_profile(profile)` — builder for non-default profile - `Fnox::profile()` / `Fnox::config()` — accessors - `Fnox::get(key) -> Option<String>` — resolves via `secret_resolver::resolve_secret`. Errors with `FnoxError::SecretNotFound { key, profile, config_path, suggestion }` (suggestion populated via `crate::suggest` → "Did you mean DATABASE_URL?") - `Fnox::list() -> Vec<String>` — declared secret names, declaration order, **sync** `Config` is held behind `Arc` — `Fnox: Clone` is cheap. Re-exported as `fnox::Fnox` from `lib.rs`. ## Removed from earlier drafts - `Fnox::discover_from(start)` — couldn't be implemented safely without a process-global `set_current_dir` violating AGENTS.md. Removed in v2. ## What's NOT here `set()` — `SetCommand::run` is ~100 LOC of provider/encryption/remote-storage branching. Substantial enough to warrant its own design pass and PR. Happy to follow up if the API shape here lands cleanly. ## Tests 8 in `library::tests`: - `open_loads_explicit_path` / `open_errors_when_path_missing` - `list_returns_declared_secrets_in_declaration_order` - `get_returns_default_value_when_no_provider` - `get_errors_with_secret_not_found_when_key_undeclared` - `get_secret_not_found_carries_did_you_mean_suggestion` (regression for the suggestion field) - `with_profile_routes_list_to_named_profile` - `clone_does_not_deep_copy_config` (asserts Arc sharing, not deep copy) Full `cargo test --lib`: **143 passed** (135 existing + 8 new), 0 failed. ## Review fixes (v1 → v2 → v3) v2 (commit 79f1a81) — addressed v1 review: - HIGH: `discover_from` was bypassing load_smart's merge chain. Removed. - MED: `list()` made sync; `Fnox` holds `Arc<Config>`; `get()` uses `Config::get_secret` not full clone; profile via `Config::get_profile(None)` (honors `FNOX_PROFILE`); error is `SecretNotFound` not `Config(...)`. v3 (commit c17f24a) — addressed v2 review: - P1: doc example used `fnox.list()` without `?` — fixed. - MED: `get()` docstring tightened to mention both `if_missing = ignore | warn`. - MED: `SecretNotFound`'s `suggestion` field now populated via `crate::suggest::find_similar` + `format_suggestions`. New regression test. ## Downstream POC [bglusman/calciforge#45](bglusman/calciforge#45) is now using this fork branch via git dep, with the wrapper collapsed from ~150 LOC of replicated orchestration to ~50 LOC of error coercion. --------- Co-authored-by: admin <[email protected]>
### 🚀 Features - **(library)** top-level Fnox::discover() / get / list convenience API by [@bglusman](https://github.com/bglusman) in [jdx#442](jdx#442) ### 🐛 Bug Fixes - **(docs)** stack banner and pin close button on mobile by [@jdx](https://github.com/jdx) in [jdx#437](jdx#437) - **(set)** fall back to current provider when updating secrets by [@rpendleton](https://github.com/rpendleton) in [jdx#439](jdx#439) ### 📚 Documentation - **(site)** show release version and github stars by [@jdx](https://github.com/jdx) in [jdx#443](jdx#443) - add cross-site announcement banner by [@jdx](https://github.com/jdx) in [jdx#434](jdx#434) - respect banner expires field by [@jdx](https://github.com/jdx) in [jdx#436](jdx#436) ### 🛡️ Security - **(build)** deterministic provider ordering in generated schema by [@jdx](https://github.com/jdx) in [jdx#432](jdx#432) ### 🔍 Other Changes - **(release)** append en.dev sponsor blurb to release notes by [@jdx](https://github.com/jdx) in [jdx#431](jdx#431) ### 📦️ Dependency Updates - bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in [jdx#435](jdx#435) - bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in [jdx#438](jdx#438) ### New Contributors - @bglusman made their first contribution in [jdx#442](jdx#442)
### 🚀 Features - **(library)** top-level Fnox::discover() / get / list convenience API by [@bglusman](https://github.com/bglusman) in [jdx#442](jdx#442) ### 🐛 Bug Fixes - **(docs)** stack banner and pin close button on mobile by [@jdx](https://github.com/jdx) in [jdx#437](jdx#437) - **(set)** fall back to current provider when updating secrets by [@rpendleton](https://github.com/rpendleton) in [jdx#439](jdx#439) ### 📚 Documentation - **(site)** show release version and github stars by [@jdx](https://github.com/jdx) in [jdx#443](jdx#443) - add cross-site announcement banner by [@jdx](https://github.com/jdx) in [jdx#434](jdx#434) - respect banner expires field by [@jdx](https://github.com/jdx) in [jdx#436](jdx#436) ### 🛡️ Security - **(build)** deterministic provider ordering in generated schema by [@jdx](https://github.com/jdx) in [jdx#432](jdx#432) ### 🔍 Other Changes - **(release)** append en.dev sponsor blurb to release notes by [@jdx](https://github.com/jdx) in [jdx#431](jdx#431) ### 📦️ Dependency Updates - bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in [jdx#435](jdx#435) - bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in [jdx#438](jdx#438) - bump communique to 1.1.2 by [@jdx](https://github.com/jdx) in [jdx#444](jdx#444) ### New Contributors - @bglusman made their first contribution in [jdx#442](jdx#442)
Per discussion #441 — "sounds fine" 👍
What
Adds a top-level
Fnoxstruct in a newsrc/library.rsmodule so downstream library consumers can write three lines instead of replicatingGetCommand::run's boilerplate.Final API surface (after v3 fixes)
Fnox::discover()—Config::load_smart(CONFIG_FILENAME)(full upward-search + parent-merge + local-override + global merge chain)Fnox::open(path)— explicit config pathFnox::with_profile(profile)— builder for non-default profileFnox::profile()/Fnox::config()— accessorsFnox::get(key) -> Option<String>— resolves viasecret_resolver::resolve_secret. Errors withFnoxError::SecretNotFound { key, profile, config_path, suggestion }(suggestion populated viacrate::suggest→ "Did you mean DATABASE_URL?")Fnox::list() -> Vec<String>— declared secret names, declaration order, syncConfigis held behindArc—Fnox: Cloneis cheap.Re-exported as
fnox::Fnoxfromlib.rs.Removed from earlier drafts
Fnox::discover_from(start)— couldn't be implemented safely without a process-globalset_current_dirviolating AGENTS.md. Removed in v2.What's NOT here
set()—SetCommand::runis ~100 LOC of provider/encryption/remote-storage branching. Substantial enough to warrant its own design pass and PR. Happy to follow up if the API shape here lands cleanly.Tests
8 in
library::tests:open_loads_explicit_path/open_errors_when_path_missinglist_returns_declared_secrets_in_declaration_orderget_returns_default_value_when_no_providerget_errors_with_secret_not_found_when_key_undeclaredget_secret_not_found_carries_did_you_mean_suggestion(regression for the suggestion field)with_profile_routes_list_to_named_profileclone_does_not_deep_copy_config(asserts Arc sharing, not deep copy)Full
cargo test --lib: 143 passed (135 existing + 8 new), 0 failed.Review fixes (v1 → v2 → v3)
v2 (commit 79f1a81) — addressed v1 review:
discover_fromwas bypassing load_smart's merge chain. Removed.list()made sync;FnoxholdsArc<Config>;get()usesConfig::get_secretnot full clone; profile viaConfig::get_profile(None)(honorsFNOX_PROFILE); error isSecretNotFoundnotConfig(...).v3 (commit c17f24a) — addressed v2 review:
fnox.list()without?— fixed.get()docstring tightened to mention bothif_missing = ignore | warn.SecretNotFound'ssuggestionfield now populated viacrate::suggest::find_similar+format_suggestions. New regression test.Downstream POC
bglusman/zeroclawed#45 is now using this fork branch via git dep, with the wrapper collapsed from ~150 LOC of replicated orchestration to ~50 LOC of error coercion.