Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ These name the deepened modules introduced by the architecture review.
- **GitAdapter** — the seam for git operations. Production: `CliGitAdapter` (shells out). Tests: `InMemoryGitAdapter`. Absorbs the commit/push/pull orchestration that was previously inline in Context.
- **SecretStore** — the deepened `remote::store` module. A struct that owns the store root and resolves `recipients_path` once at construction. Narrow interface: `read`, `write`, `list`, `recipients`.
- **SecretResolver** — the deepened secret-resolution pipeline. One module owns the full path from reference string to decrypted `DecodedSecret`. Absorbs the duplicated ref→store→decrypt→decode pipeline that was spread across 5 CLI modules.

## Architecture Terms (from 2026-06-09 review)

- **Context · project config** — Context owns "which project config applies to this invocation": `ctx.project_config()`, lazy and memoized, selecting `--project` root over cwd walk internally. The raw loaders are private to `config`; `migrate`'s multi-root scan is the one sanctioned direct caller of the explicit-root loader. The legacy-`envs:` warning firing at most once per process is a property of this module, not of call-site discipline.
- **OutputResolver** — the deepened Output pipeline: project `outputs:` map → candidates with decrypted tags → selector/alias resolution → decoded entries, consumed by exec, generate, codegen (and later the TUI outputs view). `open(ctx)` performs the single decrypt-once scan (zero I/O when no outputs are defined); `env_map(label, tags)` is exec's tri-state strict surface (local-store only, tag-filter before env-key-conflict check, conflict = hard error); `decode(output)` follows cross-store entries and preserves duplicate keys for caller-side collapse. Materialization is channel-free — `DecodedEntry` carries expiry, callers choose where warnings render. Absorbs `resolver_candidates_with_tags`, exec's inline candidate loop, and `SecretResolver::resolve_candidates`.
- **KeyRegistry** — the deepened TUI keybinding module: one exhaustive-match row per `KeyAction` (a missing row is a compile error) owning the config-field accessor, help text, view scope, and palette link. `KeyMap::entries()`, view help screens (rendered from the *live* KeyMap, so rebinds show up), and palette shortcut display all derive from the registry; non-rebindable navigation rows (arrows/esc) stay static per view. The serde `KeyMap` struct remains the user-facing config format; the legacy `envs` field is renamed `outputs` with a serde alias.
- **StoreOps** — the deepened mutation seam between presentation and the Store: one central module of silent mutation cores (set, delete, rekey, join, recipient add/rm, remote add). Each core owns the full side-effect chain — validate → encrypt → write → commit → completions-cache refresh — with no stdin/stdout. CLI commands are presentation wrappers; TUI views call the same cores, so the two fronts cannot drift. Generalizes the `add_core`/`rm_core` precedent from hm-by7.
- **PathFolding · ResultSort · StoreHealth** — three modules graduated from the Search view (`tui/model/path_folding.rs`, `tui/model/result_sort.rs`, `tui/widgets/store_health.rs`), following the autocomplete-widget precedent. PathFolding (store-bucket partitioning + collapse/expand of Secret paths to prefix groups) and ResultSort (column + direction ordering) are pure state modules — results in, rows out, no ratatui imports — unit-tested without a terminal. StoreHealth is a drawable widget owning health fetch + pill rendering; its project-config read goes through Context · project config. The Outputs view was evaluated for adoption and deliberately left out: its list is a one-line name sort, so sharing the column-sort machinery would have been a hypothetical seam.
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,9 +479,10 @@ himitsu tag prod/STRIPE_KEY rm rotate-2026-q1
absent tags. The grammar `[A-Za-z0-9_.-]+` (1-64 chars, case-sensitive)
is validated up front for both `add` and `rm`.

### `himitsu exec <REF> -- <CMD>...`
### `himitsu exec <REF>... -- <CMD>...`

Run a command with secrets injected as environment variables. `<REF>` is
Run a command with secrets injected as environment variables. One or more
refs may be passed; the union of all refs is injected. Each `<REF>` is
one of:

1. **Output label** from project config `outputs:` (e.g. `pci-prod`) -- uses
Expand All @@ -501,13 +502,16 @@ himitsu exec pci-prod -- node app.js
himitsu exec tag:pci+tag:prod -- node app.js
himitsu exec prod/*+tag:pci -- ./run-checks.sh
himitsu exec prod/API_KEY -i -- env | grep API_KEY # `-i` = clean env
himitsu exec pci-prod common/DB_URL -- node app.js # union of two refs
```

The variable name for each secret comes from (in priority order): the output
alias key → `SecretValue.env_key` → `derive_env_key(path tail)` (e.g.
`api-key` → `API_KEY`, `group/item-name` → `GROUP__ITEM_NAME`). Two
secrets resolving to the same env-var name is a hard error naming both
source paths.
source paths. With multiple refs, every ref must match at least one secret,
and an env var resolving to *different* values via different refs is a hard
error (the same secret reached by overlapping refs is fine).

If a selector matches no secrets, `himitsu exec` exits 1 with `error: selector 'X' matched no secrets`.

Expand Down Expand Up @@ -840,9 +844,10 @@ binding immediately.
The full action list (with defaults) is in
[`rust/src/tui/keymap.rs`](rust/src/tui/keymap.rs):
`quit`, `help`, `command_palette`, `new_secret`, `switch_store`,
`copy_selected`, `copy_ref_selected`, `envs`, `reveal`, `copy_value`,
`copy_ref`, `rekey`, `edit`, `delete`, `back`, `save_secret`,
`next_field`, `prev_field`, `cancel`.
`copy_selected`, `copy_ref_selected`, `outputs` (legacy alias: `envs`),
`collapse_paths`, `expand_paths`, `toggle_autocomplete`, `refine_tag`,
`sort_column`, `reveal`, `copy_value`, `copy_ref`, `rekey`, `edit`,
`delete`, `back`, `save_secret`, `next_field`, `prev_field`, `cancel`.

## Sync & Store Health

Expand Down
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ filesystem paths).
Conflicts (two secrets resolving to the same env-var name) are a hard
error: a half-injected environment is more confusing than a clear failure.

Multiple refs may be passed (`himitsu exec tag:pci prod/* -- cmd`); the
union is injected. Each ref must match at least one secret, and an env var
resolving to different values via different refs is a hard error (the same
secret reached by overlapping refs is tolerated).

Tab completion for `exec` uses fuzzy subsequence matching via
`nucleo_matcher` (`--fuzzy` flag on `__complete-paths`); other subcommands
retain exact-prefix matching.
Expand Down
46 changes: 46 additions & 0 deletions docs/adr/0001-keep-last-wins-collapse-in-file-generators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ADR-0001: Keep last-wins duplicate-key collapse in file generators

Date: 2026-06-10
Status: accepted

## Context

When an Output resolves two entries to the same env-var key, the three
consumers historically disagreed:

- `exec` hard-errors (a half-injected environment is forbidden);
- `generate` warns ("duplicate key … — using last value") and takes the
last value;
- `codegen` (sops mode) silently took the last value.

The 2026-06-09 architecture review (OutputResolver, hm-i9k) made collapse
policy explicitly caller-owned — `OutputResolver::decode` preserves
duplicate keys in entry order — and deferred the question of unifying on a
hard error to hm-x7z.

## Decision

Keep last-wins collapse in the file generators (`generate`, `codegen` sops
mode). Bring `codegen` to warning parity with `generate` (the silent
variant now warns). `exec` keeps its hard error. Do not harden the file
generators to hard errors.

## Rationale

- Within a resolved Output, alias entries resolve **after** selector
entries (`resolve_output_def`: selectors first, then aliases). Last-wins
therefore means "the alias pins this binding" — a deliberate, useful
override pattern: selectors sweep broadly, aliases pin specific keys.
Hard-erroring would break configs using that pattern.
- Generated files are reviewable artifacts (sops YAML in PRs); the
dangerous surface is `exec`'s injected environment, which already
hard-errors on conflicts.
- The remaining gap was `codegen`'s *silent* clobber — fixed by warning
parity, which is informational and non-breaking.

## Consequences

- Duplicate keys in `generate` and `codegen` warn on stderr and take the
last value; `exec` remains strict.
- Future architecture reviews should not re-propose hardening the file
generators without revisiting this ADR.
4 changes: 2 additions & 2 deletions rust/src/cli/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub fn run(args: CheckArgs, ctx: &Context) -> Result<()> {
/// 1. Explicit `args.store` slug.
/// 2. Slugs referenced in global or project config.
/// 3. All known stores (`list_remotes()`).
fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result<Vec<String>> {
fn discover_stores(args: &CheckArgs, ctx: &Context) -> Result<Vec<String>> {
// 1. Explicit store argument
if let Some(ref slug) = args.store {
config::validate_remote_slug(slug)?;
Expand All @@ -116,7 +116,7 @@ fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result<Vec<String>> {
// 2. Global + project config
let global = config::Config::load(&config::config_path()).unwrap_or_default();
let mut slugs = collect_stores_from_global_config(&global);
if let Some((cfg, _path)) = config::load_project_config().ok().flatten() {
if let Some((cfg, _path)) = ctx.project_config().ok().flatten() {
slugs.extend(collect_stores_from_project_config(&cfg));
}
if !slugs.is_empty() {
Expand Down
82 changes: 30 additions & 52 deletions rust/src/cli/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ use clap::Args;
use tracing::{debug, info};

use super::Context;
use crate::config::outputs::resolver::{
resolve_outputs, Context as ResolverContext, ResolvedOutput,
};
use crate::config::{self, load_project_config, validate_env_label};
use crate::config::outputs::resolver::ResolvedOutput;
use crate::config::validate_env_label;
use crate::error::{HimitsuError, Result};
use crate::proto::{self, CodegenLang};

Expand Down Expand Up @@ -89,25 +87,20 @@ pub fn run(args: CodegenArgs, ctx: &Context) -> Result<()> {
args.env,
);

// 2. Load project outputs config and resolve.
let outputs_map = load_project_config()?
.map(|(cfg, _)| cfg.outputs)
.unwrap_or_default();
// 2. Resolve the project's outputs (candidates carry real decrypted tags
// so `tag:` selectors and selector-valued aliases match).
let outputs = super::output_resolver::OutputResolver::open(ctx)?;

if outputs_map.is_empty() {
if outputs.all().is_empty() {
return Err(HimitsuError::InvalidConfig(
"no `outputs` defined in project config — \
define outputs: blocks in himitsu.yaml"
.into(),
));
}

let available_secrets = super::resolver_candidates_with_tags(ctx);
let resolver_ctx = ResolverContext { available_secrets };
let resolved_outputs = resolve_outputs(&outputs_map, &resolver_ctx)?;

// 3. Build inventory from resolved outputs.
let inventory = build_inventory_from_outputs(&resolved_outputs);
let inventory = build_inventory_from_outputs(outputs.all());

if inventory.all_keys.is_empty() {
return Err(HimitsuError::InvalidConfig(
Expand Down Expand Up @@ -150,55 +143,35 @@ pub fn run(args: CodegenArgs, ctx: &Context) -> Result<()> {
fn run_sops(label: &str, output_override: Option<&str>, ctx: &Context) -> Result<()> {
validate_env_label(label)?;

let outputs_map = load_project_config()?
.map(|(cfg, _)| cfg.outputs)
.unwrap_or_default();
let outputs = super::output_resolver::OutputResolver::open(ctx)?;

if outputs_map.is_empty() {
if outputs.all().is_empty() {
return Err(HimitsuError::InvalidConfig(
"no `outputs` defined in project config — \
define outputs: blocks in himitsu.yaml"
.into(),
));
}

let available_secrets = super::resolver_candidates_with_tags(ctx);
let resolver_ctx = ResolverContext { available_secrets };
let all_outputs = resolve_outputs(&outputs_map, &resolver_ctx)?;

let resolved = all_outputs
.into_iter()
.find(|o| o.name == label)
let resolved = outputs
.get(label)
.ok_or_else(|| HimitsuError::InvalidConfig(format!("unknown output: {label}")))?;

let identities = ctx.load_identities()?;
let mut output: BTreeMap<String, String> = BTreeMap::new();
for entry in &resolved.entries {
let effective_store = if let Some(ref slug) = entry.store_slug {
config::ensure_store(slug)?
} else {
ctx.store.clone()
};
let payload =
crate::remote::store::read_secret_payload(&effective_store, &entry.secret_path)?;
let plaintext =
match crate::crypto::age::decrypt_with_identities(&payload.ciphertext, &identities) {
Ok(p) => p,
Err(_) if payload.legacy_proto_envelope => payload.ciphertext,
Err(err) => return Err(err),
};
let decoded = crate::crypto::secret_value::decode_with_legacy_environment(
&plaintext,
payload.legacy_environment.as_deref(),
);
super::get::warn_if_expired(&entry.secret_path, &decoded);
let value = String::from_utf8(decoded.data).map_err(|e| {
HimitsuError::DecryptionFailed(format!(
"non-UTF-8 secret at '{}': {e}",
entry.secret_path
))
})?;
output.insert(entry.env_key.clone(), value);
for entry in outputs.decode(resolved)? {
if let Some(warning) = entry.expiry_warning() {
eprintln!("{warning}");
}
// Last-wins collapse, warned: aliases resolve after selectors, so a
// duplicate key reads as "the alias pins this binding". See
// docs/adr/0001-keep-last-wins-collapse-in-file-generators.md.
if output.contains_key(&entry.env_key) {
eprintln!(
"warning: duplicate key '{}' in output '{label}' — using last value",
entry.env_key
);
}
output.insert(entry.env_key, entry.value);
}

let body = serde_yaml::to_string(&output)?;
Expand Down Expand Up @@ -1015,6 +988,7 @@ mod tests {
key_provider: crate::config::KeyProvider::default(),
project_root: None,
git: std::sync::Arc::new(crate::git::CliGitAdapter),
project_config_cell: Default::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -1044,6 +1018,7 @@ mod tests {
key_provider: crate::config::KeyProvider::default(),
project_root: None,
git: std::sync::Arc::new(crate::git::CliGitAdapter),
project_config_cell: Default::default(),
};
let args = CodegenArgs {
env_positional: None,
Expand Down Expand Up @@ -1073,6 +1048,7 @@ mod tests {
key_provider: crate::config::KeyProvider::default(),
project_root: None,
git: std::sync::Arc::new(crate::git::CliGitAdapter),
project_config_cell: Default::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -1106,6 +1082,7 @@ mod tests {
key_provider: crate::config::KeyProvider::default(),
project_root: None,
git: std::sync::Arc::new(crate::git::CliGitAdapter),
project_config_cell: Default::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -1173,6 +1150,7 @@ mod tests {
key_provider: crate::config::KeyProvider::default(),
project_root: None,
git: std::sync::Arc::new(crate::git::CliGitAdapter),
project_config_cell: Default::default(),
};
let result = run_sops("ghost", None, &ctx);

Expand Down
9 changes: 5 additions & 4 deletions rust/src/cli/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,9 @@ fn patch_zsh(script: &str) -> String {
let is_path_positional = (trimmed.starts_with("':path -- ")
|| trimmed.starts_with("'::path -- "))
&& line.trim_end().ends_with(":_default' \\");
let is_ref_positional = (trimmed.starts_with("':ref -- ")
|| trimmed.starts_with("'::ref -- "))
let is_ref_positional = (trimmed.starts_with("':refs -- ")
|| trimmed.starts_with("'::refs -- ")
|| trimmed.starts_with("'*::refs -- "))
&& line.trim_end().ends_with(":_default' \\");
if is_path_positional {
out.push_str(&line.replace(":_default'", ":_himitsu_secrets'"));
Expand Down Expand Up @@ -450,8 +451,8 @@ mod tests {
);
let ref_line = text
.lines()
.find(|l| l.trim_start().starts_with("':ref -- "))
.expect("exec ref positional present in generated zsh script");
.find(|l| l.trim_start().starts_with("'*::refs -- "))
.expect("exec refs positional present in generated zsh script");
assert!(
ref_line.trim_end().ends_with(":_himitsu_secrets_fuzzy' \\"),
"expected exec ref positional to use fuzzy helper, got:\n{ref_line}"
Expand Down
1 change: 1 addition & 0 deletions rust/src/cli/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ mod tests {
key_provider: KeyProvider::Disk,
project_root: None,
git: std::sync::Arc::new(crate::git::CliGitAdapter),
project_config_cell: Default::default(),
}
}

Expand Down
Loading
Loading