Phase log 16: 2026-08-23 - CLI parity with the UI, and two exporter bugs it uncovered (phase 13)
Goal
Two asks:
- Fix the tag font, which read as a rendering glitch next to every other badge.
- Bring the CLI to parity with the UI - "add everything that can be done in UI, to be in cli".
Scope for (2) was settled with the user before any Rust was written: full parity
for vault data operations, plus config exporters for the four
STABLE_PROJECT_TYPES only. The seven experimental types stay UI-only rather
than existing as a second implementation that drifts. Destructive commands
prompt unless --yes.
Current Progress
Done and verified:
npm test- 662 passed (651 before; +7 parity fixtures, +4 regressions)npm run typecheck- cleancargo check --workspace- clean, no warningscargo test -p envv-cli- 6 parity tests passing- End-to-end smoke run against a scratch vault (
--db-path) and a liveenvv-serveron port 18743: entry/project/chunk/category CRUD, exports, backup round trip, RBAC, sub-user and token auth.
The tag font
.tag-chip-card / .tag-chip-sidebar (src/css/activity-tools.css) declared no
font-family, so they inherited Syne from body - a display face at 9.5px,
sitting beside badges that are all JetBrains Mono. Now mono, weight 500,
line-height: 1.5, inline-flex centring, and letter-spacing dropped to 0
(mono does not need the tracking Syne did).
The CLI
envv-cli was one 700-line main.rs with nine commands. It is now a
lib + bin split across thirteen modules, so integration tests can call the
exporters directly.
New command groups: entry, project (with project chunk), category,
tags, gen, backup, scan, status, user (with user token), class,
perm. Every pre-existing command still works with its old flags.
New global flags: --user / --token (scoped auth, remote only), --yes,
--db-path / --salt-path (a second vault, and what makes testing possible),
--init.
The bugs that mattered
Ranked by what they did to the user.
1. Disabled chunks were exported anyway
SecretChunk.disabled is documented as "excluded from exports", and the four
experimental exporters (apache, haproxy, ansible, postgres) each check it. The
four people actually deploy - WireGuard, Compose, nginx, k8s (and ssh, traefik) -
did not.
So turning a WireGuard peer off greyed the card out and then wrote it into wg0.conf regardless: the tunnel kept trusting a peer the user believed they had removed. A disabled Compose service still came up.
Fixed with a shared activeChunks(project) helper in chunk-ops.ts, used by
every stable exporter.
2. exportNginx never resolved ${…} references
It interpolated f.value raw while every other exporter resolved. The nginx
starter template ships ssl_certificate ${example_cert} fields, so a generated
nginx.conf reached the server as literal placeholder text and nginx refused to
start - while copying the same chunk from its card resolved correctly. The two
disagreed about what the config said, which is the worse half of the bug.
3. envv watch grew the vault without bound
cmd_import appended unconditionally, and watch re-ran it on every file save.
Watching a 20-line .env for an afternoon added twenty entries per save. Import
now upserts by provider (--allow-duplicates restores the old behaviour), so
watch is idempotent - the one thing it exists to do.
4. The CLI could not reach a scoped user or a LAN host
RemoteClient only ever called /api/unlock with the master password. That
meant the master credential on the wire for every command, no way to use the RBAC
the server already enforces, and no way at all to talk to a desktop "Open to LAN"
host - which refuses /api/unlock outright. --user and --token now go
through /api/auth. Verified: a read-only user's list is filtered and their
write is refused with 403 No write permissions, with the owner's data intact
afterwards.
5. A global = true argument shares its id with subcommand arguments
--user as a global with field name user collided with the positional user
in envv user token ls <user>: clap wrote the positional's value into the global,
and the command died with "--user requires --server". The global is now
as_user with long = "user". Worth remembering - nothing warns about it.
Decisions made
- Exporters for stable types only. Porting all ten would double the Rust and create ten pairs of implementations with nothing checking they agree.
- Golden-file parity instead of trusting review.
tests/fixtures/parity/holds one fixture vault and the expected output of each format.tests/cli-parity.test.tspins the TypeScript;envv-cli/tests/parity.rspins the Rust against the same bytes. Change either and one fails. Regenerate withPARITY_UPDATE=1 npx vitest run tests/cli-parity.test.ts. This is what found bugs 1 and 2 - the fixture made the app's own output legible for the first time. --db-pathimplies the salt beside it. Pairing a vault with the wrong salt derives the wrong key and reports "wrong password" for a correct password.- Ambiguous lookups are refused, never guessed.
envv entry rm gitwith GitHub and GitLab present lists both and exits non-zero. Same rule as never identifying an entry by array index: the cost of guessing is deleting the wrong secret. - A non-tty stdin refuses confirmation rather than assuming yes. A script
that forgot
--yesmust fail loudly. - The experimental gate is enforced at the write, matching
setProjectCreateType():envv project add --type kubernetesrefuses without--experimental. - Backup format is byte-compatible with the app. Verified in both directions
against Node's WebCrypto - CLI-written
.vaultbakdecrypts in the browser path, and a browser-written one restores through the CLI.
What Worked
- Writing the fixture first and letting both implementations meet in the middle. Two of the five bugs were in code nobody had touched this session.
- Reverting each fix to watch the test fail (project convention) - all four regression tests were confirmed this way.
- A real
envv-serveron a scratch port for the remote paths. jsdom cannot reach them and neither can a unit test.
What Didn't Work
- Assuming
serde_jsonpreserves object key order. It does not (BTreeMap), so a vault round-tripped through the CLI comes back with keys sorted. Harmless -entry_ckkeys onidandversion_historyonly appends whenapi_keychanges - but it means a CLI write is not byte-identical to a UI write. chunkToStringandexportNginxdisagreeing about upstreamserverlines (one splits on commas, the other does not). Both behaviours are now pinned by goldens; neither was changed, because the split-vs-not question is a real format decision, not an obvious bug.
Next Steps
- Commit. Phase 11 is still uncommitted underneath this work (listed in Handoffs 2, 3, 4). The tree also holds unrelated pre-existing changes.
- Manual smoke tests only a human can run (unchanged from Handoff 4): window state after hide-to-tray, the LAN gate while connected to a remote, the TOFU fingerprint prompt, and switching to the local vault from the startup screen.
- Ship the disabled-chunk fix knowingly. Anyone who disabled a WireGuard peer and re-exported has a wg0.conf on a server that still lists it. The fix changes what the next export writes; it cannot reach the deployed file.
- Experimental exporters are still UI-only. If the CLI needs them, port them with golden fixtures - the pattern is in place now, and it is what makes the port safe.
- Consider whether
envv entry getshould keep printing every substring match. Every other command refuses ambiguity;getstill guesses, only because changing it would break existing scripts.
Notes for whoever picks this up
- The CLI is a
lib(envv_cli) plus a thinbin. Add commands tomain.rs; add logic to the module. refs.rsis the Rust twin ofresolveFieldRef. If you touch one, touch both, and runcargo test -p envv-cli- the goldens will tell you.env_copy_field()reads~/.config/io.envvault/settings.jsonso the CLI resolves bare${Provider}refs to the same field the UI would. The parity test passes"api_key"explicitly so it does not depend on the machine.- Remote mode cannot pin a certificate:
reqwestdoes full CA validation and the fingerprint pinning lives only in Tauri'sremote_request. A self-signedenvv-serveris unreachable from the CLI. Terminate TLS at a proxy with a real cert, or add--ca-cert.
Also recorded in the rolling log
CLAUDE.original.md carried a condensed retelling of this session, kept
here so nothing is lost when that file stops being a log.
2026-08-23 - Phase 13: CLI/UI parity, exporter golden fixtures
Two asks: fix the tag font, and make the CLI able to do everything the UI can.
See the session records/16-2026-08-23-cli-parity-with-the-ui-and-two-exporter-bugs.md. 651 → 662 tests.
envv-cliis now lib + bin across thirteen modules, so tests can call the exporters directly. New groups:entry,project(+project chunk),category,tags,gen,backup,scan,status,user(+user token),class,perm. Old commands keep their flags.- Scoped auth over
/api/auth(--user,--token). Before this the CLI only knew/api/unlock, so every remote command put the master password on the wire and a desktop "Open to LAN" host was unreachable outright. - Golden-file parity between the two exporter implementations - the decision
that mattered. It immediately found two live bugs in the app: disabled chunks
were exported by every stable exporter (a disabled WireGuard peer still went
into wg0.conf), and
exportNginxnever resolved${…}so the starter template'sssl_certificatereached nginx as literal text. envv watchgrew the vault without bound - import appended unconditionally, so every save of a watched.envadded a full copy. Import now upserts.- Tag chips inherited Syne from
bodyat 9.5px while every other badge is JetBrains Mono.