Skip to content

v0.10.3

Choose a tag to compare

@github-actions github-actions released this 16 Oct 18:39
· 28 commits to main since this release
b8dbc20

evnx v0.10.3

⚠️ Why 0.10.3. By 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 patch and diff --interactive print <withheld> instead of
    values; add --show-values to a script that applies the patch.
  • convert -o FILE refuses an existing FILE without --force, and creates it 0600.
  • Exit codes: 0 clean, 1 findings, 2 no verdict (backup and restore keep
    their own documented codes) — sync --naming-policy error is now 2,
    migrate --to github-actions exits 1 when an upload failed, an unknown
    --format is 2.
  • The banner and notices are on stderr; stdout carries only what a command
    produces. -q prints nothing on success.
  • spec init --force is gone (exit 2, pointing at the new --merge).
  • validate asks a strong value of *_PWD, *_APIKEY, *CREDENTIALS and
    PASSWD names.
  • scan reads every file in a folder; a scan of a large repository may read and
    find more. --extensions restores a list, and says so.
  • add custom writes placeholders, not your values, into .env.example.
  • restore sends a file it cannot fully parse to .restored.
  • Library: see Library below; commands::validate::checks::is_secret_shaped
    was removed and several run functions changed signature.

⛔ Security — diff printed secrets

  • diff --format patch and diff --interactive print <withheld> in place
    of values unless --show-values is given (I-071, DEC-G7). They printed every
    real value from .env — database passwords, API secrets — with no flag. With
    --show-values they print the exact values (a patch has to apply), and a
    warning on stderr says the output holds secrets.
    ⚠️ A script that saves diff --format patch and applies it must now add
    --show-values.
  • --show-values masks credentials inside values (I-076, DEC-G9). It masked
    by the variable's name only, so DATABASE_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 rules scan uses. In pretty
    and json; without --show-values nothing 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 sync now refuse
    credentials
    (I-113, DEC-S1), as allow_actual already 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 detectors evnx scan uses — gets its placeholder instead and is named in a warning; the other
    keys keep their real values. One y used to commit sk_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 FILE creates 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 reruns convert -o must add
    --force. With it, the file is replaced atomically, keeps its mode, and evnx
    prints Replaced FILE; if that kept mode lets others read it, a warning names
    the chmod command (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 .env with 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). --output naming 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 0640 sets a mode on request.
    --output - prints the rendered text on stdout and writes no file; it used to
    write a file named - (DEC-T8).
  • init and add create .env 0600 and .env.example 0644 (I-237). doctor
    no longer warns about the .env that init just 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 --output is refused and no longer renamed aside
    (part of I-146).
  • restore replaces its output atomically; a crash could leave a half-written
    .env (I-151).

⛔ Fixed — doctor --fix reported repairs it had not made

  • doctor --fix checks again after every fix (I-156, DEC-D1). With
    .gitignore reading .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 --fix for it. A later
    !.env is your line: doctor reports it and does not edit it.
  • A committed .env is reported as tracked by git (I-157, DEC-D2), with
    git rm --cached .env and a pointer to evnx scan — its values are already in
    the repository history. It used to say "NOT in .gitignore", which is false
    when .gitignore covers it. --fix never runs git.
  • --fix prints one Fixed: line per change, on stderr (I-165): the
    .gitignore lines it added, and .env mode 644 → 600.
  • doctor --fix writes its .gitignore lines under the same evnx section as
    init (I-168).
  • template --gitignore asks git whether the output is ignored (I-139,
    DEC-T11). *.yml already covering out.yml no longer adds a redundant line;
    out.yml then !out.yml is no longer called "already in .gitignore"; a tracked
    output gets git rm --cached instead of a .gitignore line.
  • One .gitignore writer for init, add, doctor and template; 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 error exits 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 --format and sync --check --format refuse an unknown value with
    exit 2 and the valid values (I-074). diff --format xml used to print the
    pretty report and exit 1, so a typo in a CI step quietly broke a JSON
    contract.
  • migrate --to github-actions exits 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.
  • diff and sync --check errors 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 shared Error: 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.
  • --yes never replaces a secret that already exists: with neither
    --overwrite nor --skip-existing the 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=value writes a placeholder into .env.example and
    the value only into .env
    (I-245, DEC-U8-1; numbered I-240 in #182). The
    value went into both, so add custom STRIPE_SECRET_KEY=sk_live_… put a live
    key in the committed file.
    Every name gets a placeholder (the same ones sync writes, such as
    YOUR_KEY_HERE), not only secret-looking ones. ⚠️ If .env does 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
    got your_value_here while .env.example got 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, so config/id_rsa.pem — a private key — or a
    live key in app.swift was 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
    new skipped list (and summary.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 --merge adds only the variables [vars] does not declare
    yet
    (I-178, DEC-SP3), and keeps every existing entry and the rest of
    .evnx.toml byte for byte. --force appended 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. ⚠️ --force now exits 2
    with a pointer to --merge.
  • spec init never writes a .evnx.toml that does not parse (I-179). An
    inline vars = { … } 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 --check checks .env.example against 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 --check as a gate. With no spec either there is nothing to compare:
    exit 2, nothing to compare. --format json adds "compared_with": "spec"
    in that case only.
  • New --skip-without-env: with no .env and nothing to compare, skip
    (exit 0) and say so on stderr. For pre-commit hooks; not for CI.
  • The evnx-sync pre-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=8080 as 8000, LOG_LEVEL=info as
    YOUR_VALUE_HERE.

Fixed — doctor, init --from-source and init --detect

  • doctor gives a verdict on a Pipenv or poetry.lock-only project
    (I-175). It read pyproject.toml unconditionally 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-source no longer calls a variable the code reads "named
    nowhere in the source"
    (I-239). process.env.X and import.meta.env.X
    were found, listed as already in the template, and in the same report listed
    as unused.
  • init --detect finds the database an sqlx project uses (I-240). sqlx
    names no database itself — its features do — so an Axum + sqlx project got no
    database at all. Feature postgres proposes PostgreSQL and mysql proposes
    MySQL / MariaDB, shown as sqlx (postgres); sqlite, or no database feature,
    proposes none and says so in one line.
  • init --from-source no 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.example as 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

  • validate asks a strong value of *_PWD, *_APIKEY, *CREDENTIALS and
    PASSWD
    (F6, DEC-G14). It had its own name rule, and ADMIN_PWD=123,
    STRIPE_APIKEY=123, CREDENTIALS=123 and PASSWD=123 validated clean while
    diff and scan already treated those names as secrets. ⚠️ A .env that
    passed validate may now report weak_secret for one of them (exit 1).
    --fix still replaces a real value only with --rotate-weak-secrets.
  • Unchanged, on purpose: validate still 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's PWD; masking, scan and sync
    still hide all of them. Both levels now live in core::classify.

Fixed — convert and cloud pull

  • convert refuses names that become one name after renaming (I-088,
    DEC-U8-3). With db_host=a and DB_HOST=b, --transform uppercase printed
    {"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 run refuses vault variables that make a program run other code
    (I-228, DEC-CL1): PATH, LD_*, DYLD_*, NODE_OPTIONS, BASH_ENV,
    PYTHONPATH, JAVA_TOOL_OPTIONS and 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; --exclude them, or pass
    --allow-unsafe-names if the vault is meant to set them.
  • cloud run no longer hands evnx's own settings to the child (I-227):
    EVNX_TOKEN, EVNX_PASSWORD, EVNX_SERVER and 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 --force is the gate it says it is (I-214, DEC-OR1): it sent
    force: true whenever seats were assigned, so --yes alone deleted an
    organisation with seat holders. Without --force that is now refused, naming
    the flag.
  • org create checks the slug and name, and org invite the email, against the
    server's own rules before sending anything (I-215, I-216).
  • org remove, delete, leave and auth devices disavow ask with the shared
    confirmation: piped y no longer confirms, and without a terminal they stop
    and name --yes (I-218).
  • org invites shows 2026-10-20 09:00, not the raw timestamp (I-219); every
    cloud command now formats times one way.
  • vault share and vault role accept --role Admin (I-223).
  • cloud link <vault-id> writes the vault's name, not vault = "/" (I-221).
  • cloud unlink removes .evnx.toml when nothing else is left in it (I-224).
  • cloud status reports an EVNX_TOKEN instead of "not signed in" (I-232).
  • cloud export refuses to write into a git repository unless -o names the
    place or --force accepts it, and every export gets a .gitignore of *
    (I-230, DEC-CL6). The default ./evnx-export is 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). With EVNX_TOKEN set
    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 revoke needs at least 4 characters of a session id
    (I-204, DEC-AU1). An empty target matched every session, so
    evnx auth sessions revoke "$ID" -y with $ID unset 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 status says which credential it used (using API token (from EVNX_TOKEN) or signed in on this machine); auth logout with only
    EVNX_TOKEN says a token is ended with evnx 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 (was backup-codes, I-202) and auth sessions list (was the
    group auth sessions, twice, I-208).
  • auth token list lines 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, so evnx org uninvite "../members/u-bob" sent DELETE /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-days takes 1 to 3650 (I-205, I-210,
    DEC-AU2). 0 and -5 created a token that had already expired. The server now
    enforces the same range.
  • cloud audit --last takes 1d to 3650d or all (I-231, DEC-CL7). A huge
    value such as 9223372036854775807d crashed the command (exit 101).
  • -y now works as --yes on auth rotate-master-password, org remove,
    org delete, org leave and cloud 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 $HOME with your home folder, and ran
    $(…) and backticks inside a value. Every value is now single-quoted; vercel
    gets it through printf '%s' instead of echo, 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-project and --repo were 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 names A-Za-z0-9/_+=.@-; --repo must be owner/repo; the others
    allow letters, digits and ._-@+=:/. Names that pass are still quoted.
  • --from environment needs --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 #

  • sync and add custom quote a value when it needs it (I-114). They wrote
    KEY=value raw, so APP_NAME="My App # prod" came back as My App — the
    rest read as a comment — and MSG=hello # world as hello; leading and
    trailing spaces were lost too. One writer now serves sync, add, init's
    schema output and validate --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 --fix used
    to refuse one).
  • restore puts a valid .env back where it belongs (I-145). Its "is this
    a .env?" check was a line heuristic that rejected export A=1 and files with
    a PEM block, and wrote such backups to .env.restored. It now asks the parser
    every command reads .env with. backup no 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:

  • validate reports more placeholders: API_KEY=dev, DB_PASSWORD=test
    (a placeholder word under a secret name, DEC-G11), ***** and AWS's
    documentation key AKIAIOSFODNN7EXAMPLE (DEC-G10). ENVIRONMENT=development
    and MODE=test are still real values.
  • scan treats <set-me>, change-me, none, null and an empty value as
    placeholders
    (DEC-G10).
  • validate --fix still never treats prefix forms such as test_db_pass,
    example_value or abc123 as placeholders, so it never overwrites them
    (DEC-G13).

Changed — symlinks and existing file modes

⚠️ One rule now, in every command: a symlink at the output is written
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).

  • sync used to replace a symlinked .env.example with a plain file (I-122), and
    forced 0600 on a .env you had set to 0640 (I-123). A new .env.example from
    sync is now 0644 (it came out 0600 by accident).
  • backup and restore refused a symlinked output; they now write through it.
  • restore over an existing .env used to force it to 0600; it now keeps its
    mode. When a file that holds secrets is readable by other users, evnx warns
    and names chmod 600 FILE instead of changing it.

Security — dependency

  • rustls 0.23.45 → 0.23.46 (through reqwest, used by the cloud commands,
    migrate --to github-actions and update): RUSTSEC-2026-0346 (Debug output 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

⚠️ Scripts that read stderr or stdout line by line should check this.
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.toml now 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 in sync, the .gitignore remedy and
    conflict list in add, the .restored fallback note in restore.
  • scan -q no longer prints the banner (part of I-060).
  • cloud pull, push, run, history and 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 link and cloud unlink print nothing on success.
    backup drops the "Important" reminder too (I-079, I-060, I-164, I-153).
  • doctor -q with a finding prints the full report and exits 1, as before.
  • migrate -q drops "Loaded N secrets" and "Next steps" (I-105). The
    destination's own intro lines stay until --print-only (I-111).
  • cloud status -q and auth status -q print the answer only (server, account,
    status, token, vault): no heading, hints or Docs line.
  • restore -q --inspect and --dry-run still print the backup information.
  • The -q help 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) and core::permissions (is_private,
    make_private, mode, PRIVATE, SHARED, MODES_APPLY) — the one way evnx
    writes files (C-04).
  • core::safe_write::stage_private and Staged::commit: prepare a write, do
    something else (backup rotates), then make it visible.
  • commands::template::run_with adds the output's file mode; run is unchanged.
  • utils::write_secure is deprecated in favour of core::safe_write::write_private.
  • New: core::classify (is_secret_shaped, is_sensitive_key, holds_secret,
    url_password) and core::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_protected now delegates to is_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::ConvertConfig has a new public field force (and
    ConfigBuilder::force). Code that builds the struct literally must add it;
    the builder and Default are unaffected.
  • utils::ui::print_header_stderr is deprecated: print_header writes to stderr
    now, and both are hidden by -q. New: utils::ui::warning_detail.
  • utils::ui::warning and utils::ui::error write to stderr.
  • New: core::verdict (EXIT_OK, EXIT_FINDINGS, EXIT_ERROR, Verdict,
    finish, no_verdict, Stage) — the exit-code contract (C-04).
    ⚠️ commands::sync::run and commands::migrate::run return
    Result<Verdict> instead of Result<()> and no longer exit the process;
    sync::EXIT_* are now aliases of the core::verdict constants.
  • New: utils::prompt::NoTerminal, the typed error require_terminal and
    require_terminal_or return (same message as before).
  • commands::migrate::MigrateArgs and MigrationOptions have a new public
    field yes. MigrationOptions literals with ..Default::default() are
    unaffected; a full MigrateArgs literal must add it.
  • New: core::envfile (quote, line, is_dotenv, count_vars) — the one
    way evnx writes a .env line and decides whether text is a .env (C-01).
    utils::looks_like_dotenv and utils::count_dotenv_vars now ask it.
  • New: core::shell (quote, word, assignment), the one way evnx writes a
    shell word (A-16); formats::quoting::shell_single now 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_variable asks it.
  • New (U7, C-09): cloud::client::api_path builds every cloud request path;
    Client::require_user_session and Client::require_credential are the one
    credential guard (the five require_login copies 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_shaped is removed — use
    core::classify::is_secret_name(key, Strictness::Strict); the Lenient level
    is what is_sensitive_key answers. commands::scan::run takes a trailing
    extensions: Option<(Vec<String>, &str)>, commands::sync::run a trailing
    skip_without_env: bool, and commands::spec::run a merge: bool before
    force. New: scan::filters::{Collected, MAX_WALK_BYTES},
    FileFilter::{with_extensions, extensions, collect},
    ScanRunner::with_extensions; ScanResults::skipped and
    Summary::files_skipped; core::config::ScanPolicy::extensions.

Fixed

  • scan and init --from-source walk folders in name order. They followed
    the order the filesystem returns, which differs between machines, so the same
    project printed a different scanning … line and findings order (including in
    JSON and SARIF) in CI than on a laptop, and init --from-source could report a
    variable at a different file (I-243).

Tests

  • The training demos for sync, template, convert, diff, doctor,
    migrate, spec, scan, validate, init and backup/restore run in CI
    scene by scene (tests/<cmd>_runner.rs, 140 scenes). Each scene must match
    tests/golden/demos/<cmd>/<scene>.txt, and a check marked KNOWN that 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.rs pins the [vars] block spec init --stdout prints;
    spec_integration parses 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 how convert -o writes: 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::gitignore gains unit
    tests against a real git repository and a CRLF file.
  • tests/diff_values.rs (4) pins I-071 and I-076; core::classify and
    core::mask have 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): core imports no command, and no
    placeholder or mask function outside core decides on its own.
    tests/validate_placeholders.rs pins 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 from evnx commands --json: each one has a -q success 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) drives migrate --to github-actions against a local mock of the
    GitHub API and pins I-106; tests/architecture.rs adds that sync and
    migrate never call process::exit themselves.
  • tests/migrate_exit.rs adds 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 for sync and add custom and I-145 for
    restore; core::envfile round-trips 24 awkward values (CRLF, PEM, $NAME
    next to a defined variable, mixed quotes) through the real parser;
    tests/architecture.rs refuses a raw KEY=value write in sync, add or schema.
  • tests/migrate_quoting.rs (8, Unix) runs the printed commands of aws, azure,
    vercel, heroku, doppler, infisical and railway through sh with 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::shell unit test feeds 15 values through sh (T-SHARE-11).
  • tests/cloud_inputs.rs (3): token lifetime, audit window and -y on every
    --yes (T-SHARE-52), each refused with no request. api_path is tested against
    what reqwest::Url really sends; tests/architecture.rs refuses a hand-built
    /api/v1 path in src/cloud and a credential check outside Client.
  • 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 in src/ 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.rs adds I-088; cloud::sync unit test for
    --file - (I-244, also checked against a local server).
  • tests/scan_walk.rs (5): I-047 — a .pem and a .swift file are read,
    binary and over-size files are skipped and named, the list is opt-in from the
    flag and from .evnx.toml and announced either way, a named file is always
    read. The scan demo's KNOWN I-047 line is flipped to FIXED.
  • tests/secret_names.rs (3): F6 — validate checks the four credential names
    of U3's eight (red first), leaves the other four and PWD alone, and masking
    stays lenient; core::classify unit 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 by footer and failed on the
    Windows runner when the runs straddled a minute (T-074, missed by its first
    fix). Both now use without_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 an init::source unit test for
    I-239 (T-INIT-29), init::detect unit tests for I-240 (T-INIT-30) and an
    init::source unit test for I-246 — each red first. Demo scenes flipped from
    KNOWN to FIXED: sync S4 and S10, doctor DR10, spec SP3 and SP4.
  • tests/migrate_exit.rs: t_i109_esc_at_the_confirmation_uploads_nothing sent
    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
    uses cancel_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 | bash

Homebrew (macOS and Linux)

brew install urwithajit9/evnx/evnx

Scoop (Windows, user-local)

scoop bucket add evnx https://github.com/urwithajit9/scoop-evnx
scoop install evnx

Winget (Windows, system-wide)

winget install urwithajit9.evnx

npm / Node.js

npm install -g @evnx/cli

Python (pipx)

pipx install evnx

Cargo

cargo install evnx --all-features

cargo install compiles from source with default = [], so plain
cargo install evnx gives you a binary without the auth,
vault and cloud commands. 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.sha256

What'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