Repository navigation
v0.10.3
evnx v0.10.3
docs/RELEASING.md this would be a minor bump: it changes
the output and exit codes of shipped commands and removes a flag. It is a patch
by decision, the convention set in 0.10.1: each review release ships as a
0.10.x, and 0.11.0 stays reserved for the first stable release after the
command-by-command review, which is not finished (the per-command docs pass and
the issues still marked KNOWN in the demo locks remain).
ⓘ This release is the cross-command pass. 0.10.1 and 0.10.2 fixed one
command each. The reviews of the other commands found the same defects again
and again — files written world-readable, secrets printed or committed, exit
codes that meant different things, values truncated at # — so this release
fixes them once, in shared modules every command now goes through, and then
fixes each command's remaining critical issues. Every changed output is in a
reviewed demo golden, and 34 demo checks across 8 commands fail on the released
0.10.2 — each names the issue it fixes.
⚠️ Breaking, at a glance
Each is explained in its section below.
diff --format patchanddiff --interactiveprint<withheld>instead of
values; add--show-valuesto a script that applies the patch.convert -o FILErefuses an existing FILE without--force, and creates it 0600.- Exit codes: 0 clean, 1 findings, 2 no verdict (
backupandrestorekeep
their own documented codes) —sync --naming-policy erroris now 2,
migrate --to github-actionsexits 1 when an upload failed, an unknown
--formatis 2. - The banner and notices are on stderr; stdout carries only what a command
produces.-qprints nothing on success. spec init --forceis gone (exit 2, pointing at the new--merge).validateasks a strong value of*_PWD,*_APIKEY,*CREDENTIALSand
PASSWDnames.scanreads every file in a folder; a scan of a large repository may read and
find more.--extensionsrestores a list, and says so.add customwrites placeholders, not your values, into.env.example.restoresends a file it cannot fully parse to.restored.- Library: see Library below;
commands::validate::checks::is_secret_shaped
was removed and severalrunfunctions changed signature.
⛔ Security — diff printed secrets
diff --format patchanddiff --interactiveprint<withheld>in place
of values unless--show-valuesis given (I-071, DEC-G7). They printed every
real value from.env— database passwords, API secrets — with no flag. With
--show-valuesthey print the exact values (a patch has to apply), and a
warning on stderr says the output holds secrets.
⚠️ A script that savesdiff --format patchand applies it must now add
--show-values.--show-valuesmasks credentials inside values (I-076, DEC-G9). It masked
by the variable's name only, soDATABASE_URL=postgres://admin:s3cr3t@db/app
printed the password. A URL now keeps its structure with the password masked
(postgres://admin:***@db/app), and a token-shaped value is masked whole
even under a harmless name — the same built-in rulesscanuses. Inpretty
andjson; without--show-valuesnothing about a value is shown, as before.
⛔ Security — sync's "actual values" choices copied credentials into .env.example
- The two interactive "actual values" choices of
syncnow refuse
credentials (I-113, DEC-S1), asallow_actualalready did: "Yes, add with
actual values" for an existing template, and "Actual values copied from .env"
for a new one. A key whose value holds a credential — by the detectorsevnx scanuses — gets its placeholder instead and is named in a warning; the other
keys keep their real values. Oneyused to commitsk_live_…or a database
password. A new template keeps the.env's comments and order. [AUDIT] actual values written to .env.example for: …goes to stderr every
time real values are written, not only with--verbose.
⛔ Security — convert -o wrote every secret world-readable
convert -o FILEcreates FILE with mode 0600, not the umask's 0644 (I-081).
The file holds every value from.env.⚠️ An existing FILE is refused unless--force(exit 2, nothing written).
It used to be replaced in silence. A script that rerunsconvert -omust add
--force. With it, the file is replaced atomically, keeps its mode, and evnx
printsReplaced FILE; if that kept mode lets others read it, a warning names
thechmodcommand (DEC-G5, DEC-G6).- The input is never the output:
-o .env, or a symlink or hard link to it, is
refused even with--force. It used to replace.envwith its own conversion. - A symlinked FILE is written through to its target, and the link stays.
- A missing output directory says so in one line.
⛔ Security — files holding secrets were created readable by everyone
Every file evnx writes now goes through one writer (core::safe_write): written
to a temp file beside it and renamed into place, so a crash leaves the old file
or the new one, never half of either.
template: the rendered file is created 0600 (it was 0644 while holding
real values, I-130).--outputnaming the template or the env file — by path,
symlink or hard link — is refused and nothing is written (it used to replace
the template with real values, I-131). An existing output is replaced with
Replaced FILE(I-132). New--mode 0640sets a mode on request.
--output -prints the rendered text on stdout and writes no file; it used to
write a file named-(DEC-T8).initandaddcreate.env0600 and.env.example0644 (I-237).doctor
no longer warns about the.envthatinitjust made.backup: rotation of older backups happens only after the new one is on
disk; a failure before then leaves your previous backup where it was (I-150).
An existing directory at--outputis refused and no longer renamed aside
(part of I-146).restorereplaces its output atomically; a crash could leave a half-written
.env(I-151).
⛔ Fixed — doctor --fix reported repairs it had not made
doctor --fixchecks again after every fix (I-156, DEC-D1). With
.gitignorereading.env*then!.env, it showed ✓ and exited 0 while git
still committed.env. Now a problem that is still there after the fix is
"not fixed", the exit is 1, and doctor stops offering--fixfor it. A later
!.envis your line: doctor reports it and does not edit it.- A committed
.envis reported as tracked by git (I-157, DEC-D2), with
git rm --cached .envand a pointer toevnx scan— its values are already in
the repository history. It used to say "NOT in .gitignore", which is false
when.gitignorecovers it.--fixnever runs git. --fixprints oneFixed:line per change, on stderr (I-165): the
.gitignorelines it added, and.env mode 644 → 600.doctor --fixwrites its.gitignorelines under the same evnx section as
init(I-168).template --gitignoreasks git whether the output is ignored (I-139,
DEC-T11).*.ymlalready coveringout.ymlno longer adds a redundant line;
out.ymlthen!out.ymlis no longer called "already in .gitignore"; a tracked
output getsgit rm --cachedinstead of a.gitignoreline.- One
.gitignorewriter forinit,add,doctorandtemplate; a.gitignore
with CRLF line endings keeps them.
Changed — exit codes mean the same thing in every command
0 is "ran, nothing to report", 1 is "ran, and the answer is no", 2 is "could
not run — no verdict". ⓘ backup and restore keep their own specific codes
(2–6, listed in their guides), unchanged. Four places broke that:
sync --naming-policy errorexits 2, not 1 (I-116, DEC-S13). Under
--check, 1 means "out of sync", so a refused variable name read as drift.
The error now ends with the same "No verdict" line as every other command.diff --formatandsync --check --formatrefuse an unknown value with
exit 2 and the valid values (I-074).diff --format xmlused to print the
pretty report and exit 1, so a typo in a CI step quietly broke a JSON
contract.migrate --to github-actionsexits 1 when an upload failed (I-106). It
listed "✗ 1 failed" and exited 0, so a step that migrated half its secrets
passed. A run that cannot start (no token, GitHub unreachable) is still 2.diffandsync --checkerrors end with "No verdict: evnx did not finish"
like every other command;sync --check's own "evnx could not check:" line
is replaced by the sharedError:line.
Added — migrate --to github-actions runs unattended
--yes(-y) uploads without asking "Proceed with migration?" (I-109).
Without a terminal and without--yes, the upload now stops before contacting
GitHub, exit 2, and names--yes; it used to fail with "not a terminal" after
listing the repository's secrets, so it could not run in CI at all.--yesnever replaces a secret that already exists: with neither
--overwritenor--skip-existingthe run lists them and uploads nothing
(exit 2).- At a terminal both questions are answered on Enter, like every other yes/no
prompt, and Esc cancels: "Cancelled. Nothing was uploaded."
⛔ Security — add custom put your values in .env.example
evnx add custom NAME=valuewrites a placeholder into.env.exampleand
the value only into.env(I-245, DEC-U8-1; numbered I-240 in #182). The
value went into both, soadd custom STRIPE_SECRET_KEY=sk_live_…put a live
key in the committed file.
Every name gets a placeholder (the same onessyncwrites, such as
YOUR_KEY_HERE), not only secret-looking ones.⚠️ If.envdoes not exist
it is created, mode 0600, so the value is not lost.- A name given without a value gets the same placeholder in both files
(FEATURE_FLAG=YOUR_VALUE_HERE,STRIPE_SECRET_KEY=YOUR_KEY_HERE)..env
gotyour_value_herewhile.env.examplegot the uppercase one. - The prompt for a value says where it goes —
Value for NAME (.env only; .env.example gets YOUR_KEY_HERE). It said "Example/placeholder value", true
only before the fix above.
⛔ Security — scan did not read a .pem file in a folder
- A folder scan reads every file (I-047, DEC-U8-2). It read only files with
an extension on a built-in list, soconfig/id_rsa.pem— a private key — or a
live key inapp.swiftwas never opened, and the scan said "No secrets
detected" and exited 0. - Binary files and files over 1024 KiB in a folder are skipped, and the scan
says so:skipped N file(s) …on stderr, each file and why with-v, and a
newskippedlist (andsummary.files_skipped) in--format json— added
keys only. A file named on the command line is
read whatever its size or extension, as before. ⚠️ The old behaviour is opt-in and announced:--extensions env,yml,json,
or[scan] extensions = ["env"]in.evnx.toml. A run limited this way says
scanning only files ending in …and where the list came from, and the
config key is shown as an override. A CI scan of a large repository may read
more files than before and find more.
⚠️ Breaking — spec init --force is gone; --merge replaces it
evnx spec init --mergeadds only the variables[vars]does not declare
yet (I-178, DEC-SP3), and keeps every existing entry and the rest of
.evnx.tomlbyte for byte.--forceappended a second[vars]"to be merged
by hand"; with any repeated name TOML refused the file, and every command
that reads the config exited 2 until someone did.⚠️ --forcenow exits 2
with a pointer to--merge.spec initnever writes a.evnx.tomlthat does not parse (I-179). An
inlinevars = { … }slipped past the old check and the append broke the
file; now it is seen, and a write that would not parse is refused with the
file left as it was.
Fixed — sync --check without a .env, and the evnx-sync hook
- With no
.env,sync --checkchecks.env.exampleagainst the spec
(DEC-S4): every required[vars]name must be declared in the template —
exit 1 names the ones that are not. CI, which has no.env, can now use
sync --checkas a gate. With no spec either there is nothing to compare:
exit 2,nothing to compare.--format jsonadds"compared_with": "spec"
in that case only. - New
--skip-without-env: with no.envand nothing to compare, skip
(exit 0) and say so on stderr. For pre-commit hooks; not for CI. - The
evnx-syncpre-commit hook works (I-128, DEC-S15). It ran
evnx sync --direction forward --placeholder, which with no terminal failed
"not a terminal" on any drift and "file not found" on a clone with no.env
— blocking every new contributor. It now runs
evnx sync --check --skip-without-env: drift blocks the commit with the
command that fixes it; a fresh clone commits. New optional hook
evnx-sync-fix(evnx sync --force --skip-without-env) writes the missing
names with placeholder values.
Fixed — sync --direction reverse keeps the template's values
- Reverse copies
.env.example's own values into.env(I-115, DEC-S2),
and generates a placeholder only where the template has none — through
--template-config, which reverse used to ignore.REDIS_URL=redis://localhost:6379
came back as…/0,API_PORT=8080as8000,LOG_LEVEL=infoas
YOUR_VALUE_HERE.
Fixed — doctor, init --from-source and init --detect
doctorgives a verdict on a Pipenv orpoetry.lock-only project
(I-175). It readpyproject.tomlunconditionally and aborted: "No such file
or directory", exit 2. It now reads whichever manifest exists, and only under
--verbose, where the answer is used.init --from-sourceno longer calls a variable the code reads "named
nowhere in the source" (I-239).process.env.Xandimport.meta.env.X
were found, listed as already in the template, and in the same report listed
as unused.init --detectfinds the database ansqlxproject uses (I-240).sqlx
names no database itself — its features do — so an Axum + sqlx project got no
database at all. Featurepostgresproposes PostgreSQL andmysqlproposes
MySQL / MariaDB, shown assqlx (postgres);sqlite, or no database feature,
proposes none and says so in one line.init --from-sourceno longer proposes values the framework fills itself
(I-246).import.meta.env.DEV,MODE,PROD,SSR,BASE_URL(Vite) and
SITE,ASSETS_PREFIX(Astro) were written into.env.exampleas variables
to set. They are never proposed now, and a template that already lists one is
not called unused.
Changed — one secret-name rule, at two levels
validateasks a strong value of*_PWD,*_APIKEY,*CREDENTIALSand
PASSWD(F6, DEC-G14). It had its own name rule, andADMIN_PWD=123,
STRIPE_APIKEY=123,CREDENTIALS=123andPASSWD=123validated clean while
diffandscanalready treated those names as secrets.⚠️ A.envthat
passedvalidatemay now reportweak_secretfor one of them (exit 1).
--fixstill replaces a real value only with--rotate-weak-secrets.- Unchanged, on purpose:
validatestill ignores a setting whose name merely
contains a credential word (AUTH_ENABLED,QUOTA_FREE_TOKENS,API_KEY_ID,
SECRET_KEY_ROTATION_DAYS) and the shell'sPWD; masking,scanandsync
still hide all of them. Both levels now live incore::classify.
Fixed — convert and cloud pull
convertrefuses names that become one name after renaming (I-088,
DEC-U8-3). Withdb_host=aandDB_HOST=b,--transform uppercaseprinted
{"DB_HOST":"b"}and exited 0 — one value silently gone. Now exit 2, both
names listed, nothing written.cloud pull --file -writes to stdout (I-244, DEC-U8-4), with the status
lines on stderr. It wrote the plaintext to a file named-in the working
directory.
⛔ Security — cloud run, and what an API token may do
cloud runrefuses vault variables that make a program run other code
(I-228, DEC-CL1):PATH,LD_*,DYLD_*,NODE_OPTIONS,BASH_ENV,
PYTHONPATH,JAVA_TOOL_OPTIONSand similar. Any name from the vault was
injected, so anyone who could push to a vault could run code in every
reader's process. Nothing runs;--excludethem, or pass
--allow-unsafe-namesif the vault is meant to set them.cloud runno longer hands evnx's own settings to the child (I-227):
EVNX_TOKEN,EVNX_PASSWORD,EVNX_SERVERand the rest stay with evnx.- An API token can no longer delete a vault or a version, share, change a
role, revoke or re-key (I-220, DEC-VL1): refused before any request. The
server refuses them too (evnx-server SV-1). Push, pull, run, list and members
stay open to tokens — what CI needs.
Fixed — evnx org, vault and cloud
org delete --forceis the gate it says it is (I-214, DEC-OR1): it sent
force: truewhenever seats were assigned, so--yesalone deleted an
organisation with seat holders. Without--forcethat is now refused, naming
the flag.org createchecks the slug and name, andorg invitethe email, against the
server's own rules before sending anything (I-215, I-216).org remove,delete,leaveandauth devices disavowask with the shared
confirmation: pipedyno longer confirms, and without a terminal they stop
and name--yes(I-218).org invitesshows2026-10-20 09:00, not the raw timestamp (I-219); every
cloud command now formats times one way.vault shareandvault roleaccept--role Admin(I-223).cloud link <vault-id>writes the vault's name, notvault = "/"(I-221).cloud unlinkremoves.evnx.tomlwhen nothing else is left in it (I-224).cloud statusreports anEVNX_TOKENinstead of "not signed in" (I-232).cloud exportrefuses to write into a git repository unless-onames the
place or--forceaccepts it, and every export gets a.gitignoreof*
(I-230, DEC-CL6). The default./evnx-exportis plaintext.
Fixed — evnx auth
- An API token can no longer change the master password, download the
account's data or delete the account (I-211, DEC-AU3). WithEVNX_TOKENset
those three went to the server — the other account commands already refused
locally. All of them now refuse before any request, with the same message. auth sessions revokeneeds at least 4 characters of a session id
(I-204, DEC-AU1). An empty target matched every session, so
evnx auth sessions revoke "$ID" -ywith$IDunset signed the machine out.- Revoking the session you are signed in with signs this machine out (I-209):
it said so but kept the dead login, so the next command failed instead. auth statussays which credential it used (using API token (from EVNX_TOKEN)orsigned in on this machine);auth logoutwith only
EVNX_TOKENsays a token is ended withevnx auth token revoke, not "nothing
to do" (I-212, DEC-AU10).- Hints that named commands which do not run are fixed:
auth totp recovery-codes(wasbackup-codes, I-202) andauth sessions list(was the
groupauth sessions, twice, I-208). auth token listlines its columns up by display width, so accented and CJK
token names no longer push the columns out (I-207).
⛔ Security — cloud requests stay on the resource you named
- Text you give a cloud command can no longer change which API resource it
reaches (I-217, DEC-OR2). It was pasted into request paths, soevnx org uninvite "../members/u-bob"sentDELETE /orgs/<org>/members/u-bob— removing
a member instead of revoking an invite — and a value with a space,?or#
became a query string. Every value is now encoded as one path segment, and
.,..and empty values are refused before anything is sent: the URL
standard collapses..(and%2E%2E) whatever the encoding. auth token create --expires-in-daystakes 1 to 3650 (I-205, I-210,
DEC-AU2).0and-5created a token that had already expired. The server now
enforces the same range.cloud audit --lasttakes1dto3650dorall(I-231, DEC-CL7). A huge
value such as9223372036854775807dcrashed the command (exit 101).-ynow works as--yesonauth rotate-master-password,org remove,
org delete,org leaveandcloud delete-version, as it already did on every
other command with--yes(I-213).
⛔ Security — the commands migrate prints ran parts of your values
- vercel and azure put values in double quotes (I-101). Running the printed
commands emptied$5, replaced$HOMEwith your home folder, and ran
$(…)and backticks inside a value. Every value is now single-quoted; vercel
gets it throughprintf '%s'instead ofecho, which also stops a trailing
newline being stored. - aws escaped an apostrophe as
\'inside single quotes (I-094), which ends
the string: the printed command was a syntax error, or ran the rest of the
value. It is now written'\''. - Names are checked before anything is printed (I-102, I-107).
--heroku-app,--vault-name,--secret-name,--aws-profile,
--vercel-project,--project,--doppler-config,--infisical-env,
--railway-projectand--repowere pasted into commands unvalidated, so
--heroku-app 'x;echo PWNED'printed a second command. A name the platform
cannot accept is refused (exit 2, naming the flag and the rule): Heroku app
names 3–30 lowercase letters, digits and dashes; Key Vault names 3–24; AWS
secret namesA-Za-z0-9/_+=.@-;--repomust beowner/repo; the others
allow letters, digits and._-@+=:/. Names that pass are still quoted. --from environmentneeds--include(I-103). It sent the whole process
environment minus 14 system names — in CI, the runner's own tokens with it.
Now it refuses without--include, and lists on stderr the names it will
send.
⛔ Fixed — values written into .env files lost everything after #
syncandadd customquote a value when it needs it (I-114). They wrote
KEY=valueraw, soAPP_NAME="My App # prod"came back asMy App— the
rest read as a comment — andMSG=hello # worldashello; leading and
trailing spaces were lost too. One writer now servessync,add,init's
schema output andvalidate --fix: plain values stay bare, a value with$
or\goes in single quotes (literal in every dotenv reader), anything else
in double quotes, and a line break is written as\n(validate --fixused
to refuse one).restoreputs a valid.envback where it belongs (I-145). Its "is this
a.env?" check was a line heuristic that rejectedexport A=1and files with
a PEM block, and wrote such backups to.env.restored. It now asks the parser
every command reads.envwith.backupno longer warns about those files.
⚠️ The other direction changed too: a file with a line the parser cannot read
(up to a fifth of the lines used to be tolerated) now restores to.restored,
the side that cannot overwrite a good.env.
Changed — one placeholder rule, with the variable's name
validate and scan each kept their own list of placeholder values, and they
disagreed on 32 of 56 common values. Both now ask one rule, at one of two levels:
Strict for validate (a placeholder verdict lets --fix overwrite the
value) and Lenient for scan and display. The lists moved unchanged; what
changes for you:
validatereports more placeholders:API_KEY=dev,DB_PASSWORD=test
(a placeholder word under a secret name, DEC-G11),*****and AWS's
documentation keyAKIAIOSFODNN7EXAMPLE(DEC-G10).ENVIRONMENT=development
andMODE=testare still real values.scantreats<set-me>,change-me,none,nulland an empty value as
placeholders (DEC-G10).validate --fixstill never treats prefix forms such astest_db_pass,
example_valueorabc123as placeholders, so it never overwrites them
(DEC-G13).
Changed — symlinks and existing file modes
through to its target and the link stays, with · wrote through LINK -> TARGET;
an existing file keeps the mode you gave it (DEC-S6, DEC-S7, DEC-B7, DEC-G6).
syncused to replace a symlinked.env.examplewith a plain file (I-122), and
forced 0600 on a.envyou had set to 0640 (I-123). A new.env.examplefrom
syncis now 0644 (it came out 0600 by accident).backupandrestorerefused a symlinked output; they now write through it.restoreover an existing.envused to force it to 0600; it now keeps its
mode. When a file that holds secrets is readable by other users, evnx warns
and nameschmod 600 FILEinstead of changing it.
Security — dependency
rustls0.23.45 → 0.23.46 (throughreqwest, used by the cloud commands,
migrate --to github-actionsandupdate): RUSTSEC-2026-0346 (Debugoutput of
client session values exposed TLS secrets, medium), -0347, -0348, -0349 (low).
Landed in #170; this entry was missing there.
Changed — what goes to stdout and what to stderr
Stdout carries what a command produces; notices about the run go to stderr. A
terminal shows both exactly as before.
- The command banner is on stderr, for every command (
evnx sync .env ↔ .env.example).evnx spec init --stdout > spec.tomlnow writes valid TOML;
it used to start with the banner, which made the file unreadable by the next
command (I-176). A script that captured stdout and looked for the banner must
read stderr. - Warnings and errors printed by evnx (
! …,✗ …) are on stderr, with
their detail lines: the permissions hint insync, the.gitignoreremedy and
conflict list inadd, the.restoredfallback note inrestore. scan -qno longer prints the banner (part of I-060).cloud pull,push,run,historyand the other vault commands print
"using from " on stderr, and not at all under-q(I-226). It
used to be the first line of stdout, ahead of the pulled file or the child's
output.- Not changed yet, and announced here one release ahead: success lines (
✓ …),
tips (· …) and "Next steps" stay on stdout in this release and are planned to
move to stderr in a later one.
Changed — -q is silent on success
-q now means what its help says: nothing on success; findings, warnings,
errors and data you asked for still print (I-007). A script that ran with -q
and read a success line must check the exit code instead.
diff(no differences),convert -o,scan(clean),doctor(healthy),
backup,restore,cloud linkandcloud unlinkprint nothing on success.
backupdrops the "Important" reminder too (I-079, I-060, I-164, I-153).doctor -qwith a finding prints the full report and exits 1, as before.migrate -qdrops "Loaded N secrets" and "Next steps" (I-105). The
destination's own intro lines stay until--print-only(I-111).cloud status -qandauth status -qprint the answer only (server, account,
status, token, vault): no heading, hints or Docs line.restore -q --inspectand--dry-runstill print the backup information.- The
-qhelp text says this, instead of "Suppress non-essential output".
Library
- New:
core::safe_write(write_private,write_shared,write_with_mode,
same_file,report,Written) andcore::permissions(is_private,
make_private,mode,PRIVATE,SHARED,MODES_APPLY) — the one way evnx
writes files (C-04). core::safe_write::stage_privateandStaged::commit: prepare a write, do
something else (backup rotates), then make it visible.commands::template::run_withadds the output's file mode;runis unchanged.utils::write_secureis deprecated in favour ofcore::safe_write::write_private.- New:
core::classify(is_secret_shaped,is_sensitive_key,holds_secret,
url_password) andcore::mask(mask,disclose,Disclosure,MASK,
WITHHELD) — one answer to "is this a secret" and one way to show it (C-05). - New:
core::gitignore::{is_protected, exposure, Exposure, append_entries}.
env_file_is_protectednow delegates tois_protected. Deprecated:
utils::git::{is_in_gitignore, add_to_gitignore}and
utils::file_ops::append_if_missing(text matching; no callers left). core::classify::{is_placeholder, Strictness}: the one placeholder rule.
utils::patterns::is_placeholder(value)now asks it (Lenient, no key) and
answers as before except for DEC-G10.⚠️ commands::validate::checks::is_placeholder
now takes the variable's name:is_placeholder(name, value).commands::convert::ConvertConfighas a new public fieldforce(and
ConfigBuilder::force). Code that builds the struct literally must add it;
the builder andDefaultare unaffected.utils::ui::print_header_stderris deprecated:print_headerwrites to stderr
now, and both are hidden by-q. New:utils::ui::warning_detail.utils::ui::warningandutils::ui::errorwrite to stderr.- New:
core::verdict(EXIT_OK,EXIT_FINDINGS,EXIT_ERROR,Verdict,
finish,no_verdict,Stage) — the exit-code contract (C-04).
⚠️ commands::sync::runandcommands::migrate::runreturn
Result<Verdict>instead ofResult<()>and no longer exit the process;
sync::EXIT_*are now aliases of thecore::verdictconstants. - New:
utils::prompt::NoTerminal, the typed errorrequire_terminaland
require_terminal_orreturn (same message as before). commands::migrate::MigrateArgsandMigrationOptionshave a new public
fieldyes.MigrationOptionsliterals with..Default::default()are
unaffected; a fullMigrateArgsliteral must add it.- New:
core::envfile(quote,line,is_dotenv,count_vars) — the one
way evnx writes a.envline and decides whether text is a.env(C-01).
utils::looks_like_dotenvandutils::count_dotenv_varsnow ask it. - New:
core::shell(quote,word,assignment), the one way evnx writes a
shell word (A-16);formats::quoting::shell_singlenow asks it.
core::args(check,check_opt,Name) checks names (A-17).
core::envsource(SKIP,is_skipped,require_include,read) reads the
environment (A-18);migrate::sources::is_system_variableasks it. - New (U7, C-09):
cloud::client::api_pathbuilds every cloud request path;
Client::require_user_sessionandClient::require_credentialare the one
credential guard (the fiverequire_logincopies delegate;vault::require_session
is gone);core::args::{MAX_DAYS, parse_days, days_before};
utils::prompt::confirm;core::envsource::{UNSAFE_NAMES, UNSAFE_PREFIXES, is_unsafe_name, is_evnx_own}. - New:
cloud::when(one timestamp format),core::args::{Name::OrgSlug, check_email},core::gitignore::in_work_tree,cloud run --allow-unsafe-names. - U8:
⚠️ commands::validate::checks::is_secret_shapedis removed — use
core::classify::is_secret_name(key, Strictness::Strict); the Lenient level
is whatis_sensitive_keyanswers.commands::scan::runtakes a trailing
extensions: Option<(Vec<String>, &str)>,commands::sync::runa trailing
skip_without_env: bool, andcommands::spec::runamerge: boolbefore
force. New:scan::filters::{Collected, MAX_WALK_BYTES},
FileFilter::{with_extensions, extensions, collect},
ScanRunner::with_extensions;ScanResults::skippedand
Summary::files_skipped;core::config::ScanPolicy::extensions.
Fixed
scanandinit --from-sourcewalk folders in name order. They followed
the order the filesystem returns, which differs between machines, so the same
project printed a differentscanning …line and findings order (including in
JSON and SARIF) in CI than on a laptop, andinit --from-sourcecould report a
variable at a different file (I-243).
Tests
- The training demos for
sync,template,convert,diff,doctor,
migrate,spec,scan,validate,initandbackup/restorerun in CI
scene by scene (tests/<cmd>_runner.rs, 140 scenes). Each scene must match
tests/golden/demos/<cmd>/<scene>.txt, and a check markedKNOWNthat flips
fails the test until its runner line is changed on purpose. Linux only. tests/backup_format_lock.rs: backups made by 0.10.0 and 0.10.2 must keep
restoring byte for byte. The key file there is a published test passphrase.tests/spec_golden.rspins the[vars]blockspec init --stdoutprints;
spec_integrationparses that stdout as TOML (T-SP-20).scripts/behaviour-lock.sh <cmd> [old] [new]runs a demo twice, or with two
binaries, and lists every line that moved.tests/convert_output.rs(8 tests) pins howconvert -owrites: mode, refusal
without--force, same file by path/symlink/hard link, write-through,-q.tests/template_output.rs(9),tests/env_modes.rs(4, T-INIT-27), a sync
symlink test and backup/sync unit tests pin the writer's rules per command.tests/doctor_gitignore.rs(5) pins I-156, I-157, DEC-D1, DEC-D2 and JSON
under--fix; three template tests pin I-139;core::gitignoregains unit
tests against a real git repository and a CRLF file.tests/diff_values.rs(4) pins I-071 and I-076;core::classifyand
core::maskhave unit tests (URL structure, no prefix, UTF-8).tests/sync_pty.rs(2) drives both "actual values" choices through a real
terminal (I-113).tests/architecture.rs(T-SHARE-01):coreimports no command, and no
placeholder or mask function outsidecoredecides on its own.
tests/validate_placeholders.rspins DEC-G11 end to end;core::classify
unit tests pin DEC-G10, G11 and G13 at both levels.tests/quiet_walk.rs(T-SHARE-60) reads every command fromevnx commands --json: each one has a-qsuccess case that must print nothing, or a stated
reason it has none. A new command fails the test until it is classified.tests/exit_verdicts.rs(6) pins I-116 and I-074;tests/migrate_exit.rs
(2, Unix) drivesmigrate --to github-actionsagainst a local mock of the
GitHub API and pins I-106;tests/architecture.rsadds thatsyncand
migratenever callprocess::exitthemselves.tests/migrate_exit.rsadds I-109 (no terminal without and with--yes,
existing secrets under--yes, Esc at the confirmation) and T-MIG-28
(--skip-existing), all against the mock GitHub API.tests/env_writer.rs(5) pins I-114 forsyncandadd customand I-145 for
restore;core::envfileround-trips 24 awkward values (CRLF, PEM,$NAME
next to a defined variable, mixed quotes) through the real parser;
tests/architecture.rsrefuses a rawKEY=valuewrite in sync, add or schema.tests/migrate_quoting.rs(8, Unix) runs the printed commands of aws, azure,
vercel, heroku, doppler, infisical and railway throughshwith stub tools and
checks each of 10 values arrives exactly and nothing runs (T-MIG-01..04, 07);
names refused and accepted (T-MIG-09, 32);--from environment(T-MIG-17,
18).core::shellunit test feeds 15 values throughsh(T-SHARE-11).tests/cloud_inputs.rs(3): token lifetime, audit window and-yon every
--yes(T-SHARE-52), each refused with no request.api_pathis tested against
whatreqwest::Urlreally sends;tests/architecture.rsrefuses a hand-built
/api/v1path insrc/cloudand a credential check outsideClient.tests/cloud_auth.rs(5): T-SHARE-51 (8 account commands refuse a token with
no request), I-204, I-209 (a real stored login is removed), DEC-AU10 for
status and logout.tests/quoted_commands.rs(T-SHARE-49): every
`evnx …`quoted insrc/is a real command, not a bare group, with real
flags — it catches I-202 and both I-208 on the old source and nothing else.tests/add_custom_example.rs(3): I-245 with and without.env, and a rerun is
not a conflict;tests/convert_output.rsadds I-088;cloud::syncunit test for
--file -(I-244, also checked against a local server).tests/scan_walk.rs(5): I-047 — a.pemand a.swiftfile are read,
binary and over-size files are skipped and named, the list is opt-in from the
flag and from.evnx.tomland announced either way, a named file is always
read. The scan demo'sKNOWN I-047line is flipped toFIXED.tests/secret_names.rs(3): F6 — validate checks the four credential names
of U3's eight (red first), leaves the other four andPWDalone, and masking
stays lenient;core::classifyunit test for all eight at both levels; the
one-definition guard now covers the secret-name rules (red first: it found
both copies).tests/init_integration.rs: two more tests compared whole.env.example
files including the minute-stamped# Generated byfooter and failed on the
Windows runner when the runs straddled a minute (T-074, missed by its first
fix). Both now usewithout_generated_stamp.- U8d:
tests/sync_reverse_values.rs(5, I-115, T-SYNC-35),
tests/sync_check_without_env.rs(10: DEC-S4, DEC-U8-5, and T-SYNC-51 reading
the shipped.pre-commit-hooks.yaml),tests/doctor_python.rs(3, I-175),
tests/spec_merge.rs(5, I-178, I-179), and aninit::sourceunit test for
I-239 (T-INIT-29),init::detectunit tests for I-240 (T-INIT-30) and an
init::sourceunit test for I-246 — each red first. Demo scenes flipped from
KNOWNtoFIXED: sync S4 and S10, doctor DR10, spec SP3 and SP4. tests/migrate_exit.rs:t_i109_esc_at_the_confirmation_uploads_nothingsent
Esc and Enter together and hung on a slow CI runner — the race #156 fixed
elsewhere (the two bytes read as one escape sequence, the Enter lost). It now
usescancel_yes_no; 20 of 20 runs passed with every core loaded.tests/cloud_org.rs(5),tests/cloud_vault.rs(3),tests/cloud_cloud.rs(4):
every Part D fix against a mock server, refusals with zero requests; the
architecture guard now reads the path text, after six multi-line hand-built
paths had passed it.
Installation
Linux / macOS (shell script)
curl -sSL https://raw.githubusercontent.com/urwithajit9/evnx/main/scripts/install.sh | bashHomebrew (macOS and Linux)
brew install urwithajit9/evnx/evnxScoop (Windows, user-local)
scoop bucket add evnx https://github.com/urwithajit9/scoop-evnx
scoop install evnxWinget (Windows, system-wide)
winget install urwithajit9.evnxnpm / Node.js
npm install -g @evnx/cliPython (pipx)
pipx install evnxCargo
cargo install evnx --all-features
cargo installcompiles from source withdefault = [], so plain
cargo install evnxgives you a binary without theauth,
vaultandcloudcommands. Every other channel above ships them
already — the binaries are built with--all-features.
Checksums
SHA256 checksums are provided for each binary. Verify after download:
sha256sum -c evnx-*.tar.gz.sha256What's Changed
- tests: lock 11 command demos before shared-module work (U0) by @urwithajit9 in #166
- term: banner, warnings and errors to stderr; spec --stdout is only TOML (U1) by @urwithajit9 in #167
- quiet: -q is silent on success in every local command; cloud notice to stderr (U1 part 2) by @urwithajit9 in #168
- safe_write: one way to write files; convert -o is 0600 and needs --force (U2 part 1) by @urwithajit9 in #169
- deps: rustls 0.23.46 (RUSTSEC-2026-0346..0349) by @urwithajit9 in #170
- safe_write adoption: template, sync, backup, restore, init, add (U2 part 2) by @urwithajit9 in #171
- gitignore: one writer, is_protected/exposure; doctor --fix re-checks (U2 part 3) by @urwithajit9 in #172
- classify, mask: one answer to "is this a secret"; diff stops printing secrets (U3 part 1) by @urwithajit9 in #173
- classify: one placeholder rule; sync's actual-values choices refuse credentials (U3 part 2) by @urwithajit9 in #174
- verdict: one exit-code contract (U4 part 1) by @urwithajit9 in #175
- migrate: --yes for unattended GitHub upload (U4 part 2) by @urwithajit9 in #176
- envfile: one .env writer and one dotenv check (U5) by @urwithajit9 in #177
- migrate: shared shell quoting, name checks, --include for environment (U6) by @urwithajit9 in #178
- cloud: shared helpers — path encoding, credential guard, days rule (U7 Part B) by @urwithajit9 in #179
- auth: account commands, sessions and hints (U7 Part C) by @urwithajit9 in #180
- cloud: org, vault and cloud fixes (U7 Part D) by @urwithajit9 in #181
- add custom, convert collisions, pull to stdout (U8a) by @urwithajit9 in #182
- scan reads every file in a folder; extension list opt-in and announced (U8b, I-047) by @urwithajit9 in #183
- One secret-name rule at two levels; validate checks *_PWD, *_APIKEY, *CREDENTIALS (U8c, F6) by @urwithajit9 in #184
- U8d: sync reverse keeps template values, sync --check without .env, working evnx-sync hook, doctor on Pipfile, spec init --merge by @urwithajit9 in #185
- Release 0.10.3: final run, docs sweep, release notes; init sqlx detect and Astro built-ins (U9) by @urwithajit9 in #186
Full Changelog: v0.10.2...v0.10.3
npm / Node.js
npm install -g @evnx/cli # global install
npx @evnx/cli --version # zero-install usage
npm install --save-dev @evnx/cli # project dev dependency