The .env.schema format
What penv adds to the @env-spec grammar in .env.schema: decorators, computed values, inference, grammar version, diagnostics and the generated .env.
# @penv=acme/api-gateway
# @currentEnv=$APP_ENV
# @type=enum(development,staging,production)
APP_ENV=development
# @type=url
DATABASE_URL=
# @type=string(startsWith=sk_) @rotate=90d @hosts(api.stripe.com)
STRIPE_SECRET_KEY=
# @type=url @sensitive=false
NEXT_PUBLIC_APP_URL=http://localhost:3000The grammar is @env-spec; upstream meanings: root decorators, item decorators. This page lists what penv 1.0.0-rc.1 adds, restricts or infers. Concept: schema and types.
penv reads the nearest .env.schema upward from your working directory, so a monorepo holds one per app. A # --- line ends a block.
Decorators
| Decorator | Placement | Value penv accepts | Origin |
|---|---|---|---|
@penv | Header | org/project or <provider>:org/project | penv |
@schema | Header | Whole number above zero (legacy, see grammar version) | penv |
@defaultSensitive | Header | Nothing, true or false | @env-spec |
@defaultRequired | Header | Nothing, true, false or infer | @env-spec |
@currentEnv | Header | A key, such as $APP_ENV | @env-spec |
@import | Header | (path, KEY, ...) or (path, pick=[KEY, ...]); a schema file, never a value file | @env-spec |
@assert | Header or key | (expression, "message") | penv |
@required, @optional | Key | Nothing, true, false or forEnv(env, ...) | @env-spec |
@sensitive | Key | Nothing, true or false | @env-spec |
@type | Key | One of the seven types | @env-spec |
@example, @docs | Key | A value, required | @env-spec |
@deprecated | Key | Nothing, or a note | @env-spec |
@dynamic, @static | Key | Nothing. Parsed and kept; penv acts on neither | @env-spec |
@hosts | Key | One host, or (a.com, "*.b.com"): at most 32, no scheme, port, path or bare * | penv |
@rotate | Key | A duration, required | penv |
penv ignores anything else with a warning, so a varlock schema still parses: varlock_only for @plugin and for other values of @required, @optional or @sensitive; unknown_decorator for the rest. penv has no @scope: read access is a role in the console.
The header is the first comment block; a block on a key is still the header when it carries a header decorator, and that key gets no decorators.
@penv
@penv takes an optional provider, then org/project with no second /; anything else is invalid_header. No @penv means local mode.
| Part | Rule |
|---|---|
<provider>: | Lowercase slug. penv 1.0.0-rc.1 has one provider, penv (us, at https://penv.cloud); another is unknown_provider. --provider overrides it. |
[providers.<slug>] url | The API root, in .penv/config.toml. penv sends a root named only there no PENV_TOKEN, CI or AWS proof (credential_withheld); PENV_URL wins (variables). |
@hosts
Under an agent, or with penv run --sealed, penv run hands the command a placeholder and puts the value into requests to the @hosts hosts only. A type with no room for a placeholder is cannot_seal (errors).
@hosts may be computed:
# @hosts=url($DATABASE_URL, host)
DATABASE_URL=[hosts]
DATABASE_URL = ["db.internal.test"]penv check and a sealed run refuse a key whose hosts differ from its pin; penv check --pin-hosts writes the pins. penv push sends a computed @hosts to penv.cloud as hostsExpr, the expression as written. We store and show it and enforce nothing; sealed runs and [hosts] pins enforce it on your machine. Config.
@assert
# @assert(not(eq($PORT, $ADMIN_PORT)), "PORT and ADMIN_PORT collide")A false expression fails penv check and penv run with the message. Expressions use the functions below.
Grammar version
[schema]
version = 1penv init writes the version here rather than as @schema in your file, because varlock rejects @schema (root decorators). penv 1.0.0-rc.1 reads up to version 1.
A higher version is unsupported_schema (config) or schema_too_new (@schema); penv upgrade fixes both. The config wins over @schema.
Types
penv accepts seven of the upstream data types. A key with no @type is a string.
| Type | Constraints penv accepts |
|---|---|
string | startsWith, endsWith, minLength, maxLength, matches |
number | min, max, isInt, precision |
boolean, url, email | none |
port | min, max |
enum(a,b,c) | none; bare arguments are members |
There is no integer; write number(isInt=true). Another type is an unknown_type warning and penv checks a string; an unknown constraint is an unknown_constraint warning and is ignored.
penv keeps matches and precision and passes them to us and to your generated types, but enforces neither (it carries no regular expression engine): a matches constraint never fails a value at penv check.
Durations
@rotate takes y, m (calendar months), w, d, h, min and s, largest first, each at most once: 90d, 1y6m, 30min. 90 days does not parse. penv check prints @rotate as a reminder counted from the value's last write, without changing its exit code.
Computed values
DATABASE_URL=postgres://app:${DB_PASSWORD | urlencode}@db:5432/app
LOG_LEVEL=${LEVEL:-info}
SESSION_SECRET=random(length=32)
SHARED_KEY=penv(production/SHARED_KEY)
LITERAL='${NOT_EXPANDED}'A value or schema default that holds ${KEY} or is one function call is computed. Upstream semantics: functions.
| Form | Origin | penv behavior |
|---|---|---|
${KEY}, ${KEY:-or} (empty or unset), ${KEY-or} (unset only) | @env-spec | A reference nothing sets computes as empty, with a warning. \$ is a literal dollar. |
ref, concat, fallback, if, eq, not, isEmpty, forEnv | @env-spec | Evaluated. |
${KEY | urlencode}, base64, b64d, lower, upper, trim | penv | Filters, left to right. b64d decodes base64. |
match(subject, label: value, _: value), and, or, startsWith, endsWith | penv | Evaluated. |
random(kind=, length=) | penv | kind is alnum, hex, base64url or uuid; length is 8 to 512. penv run generates it once and keeps it in .env.local (.env.test.local for test); penv push never sends it. random(32) fails: write random(length=32). |
url(), json(), hash() | penv | Read, write and build URL parts, JSON and hashes. Functions. |
penv() | penv | penv(KEY), penv(env/KEY), penv(project/env/KEY) or penv(org/project/env/KEY) reads that value from us; penv() alone reads the key it sits on. |
exec() | varlock | Refused: a schema never starts a process. |
| Any other function | varlock plugin | Refused; single-quote the value to keep it as written. |
A single-quoted or backticked value is never computed. A value computed from a sensitive key is sensitive; a bundler-public one fails penv check and penv run. Failures exit 3 (errors).
Inference
penv decides required and sensitive per key when no decorator says otherwise.
| Property | Rule |
|---|---|
| Required | KEY= (nothing after =) is required. KEY=value makes value the default and the key optional. @defaultRequired changes the default. |
| Sensitive | Every key is sensitive unless it carries @sensitive=false, the header says @defaultSensitive=false, or the name starts with a bundler prefix: NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_, NUXT_PUBLIC_, REACT_APP_, GATSBY_, VUE_APP_, STORYBOOK_, or one you list under [public] prefixes in .penv/config.toml. |
A required key with no value fails penv check. penv ls prints no values, and penv run scrubs sensitive values from its output. What penv init infers: from a .env file.
A bundler-prefixed key marked @sensitive fails with sensitive_public_key: the prefix ships the value in the client bundle, so penv cannot keep it secret. Drop the decorator or rename the key.
Diagnostics
penv check reports every problem with line and column and exits 3; other commands refuse the file as invalid_schema, exit 3. Warnings never change the exit code.
| Diagnostic | Cause | Fix |
|---|---|---|
invalid_line | A line that is neither a # comment nor KEY=value. | Comment it out or make it KEY=. |
invalid_key_name | A name starting with a digit, or holding more than letters, digits and _. | Rename it. |
duplicate_key | The same key is declared twice. | Keep one block. |
invalid_decorator | A non-decorator on an @ line, an unclosed @name(, or a malformed @assert. | Fix the line. |
missing_decorator_value | @example, @docs, @rotate or @type with nothing after =. | Add the value. |
invalid_decorator_value | A non-boolean on a boolean decorator, a value on @dynamic or @static, a bad @rotate, or a bad or repeated @hosts entry. | Correct it. |
unterminated_quote | A quoted decorator value is never closed. | Close the quote. |
invalid_header | @penv, @schema, @currentEnv or @import without its value. | Correct the header. |
invalid_import | The imported file is missing, a value file, circular, or lacks a picked key. | Import a schema. |
schema_too_new, unsupported_schema | @schema or [schema] version is above what this build reads, or not a whole number. | penv upgrade |
invalid_config | .penv/config.toml is not valid TOML. | Fix the named line. |
invalid_type | A missing ), a bare argument on a non-enum type, or an empty enum. | Correct it. |
duplicate_constraint | The same constraint given twice. | Keep one. |
sensitive_public_key | A bundler-prefixed or [public]-prefixed key marked @sensitive. | Drop @sensitive or rename the key. |
hosts_unpinned | A sealed run found a computed @hosts with no pin, or hosts that differ from it. | Run penv check, then penv check --pin-hosts once the hosts are right. |
hosts_uncomputed | penv check --pin-hosts could not compute a key's hosts; nothing was pinned. | Fix the @hosts it names, then pin again. |
Undeclared keys in .env are drift: penv check names them, penv run masks them, and the exit code holds.
Generated .env
penv pull and penv set write the same plain lines:
| Property | Value |
|---|---|
| Encoding | UTF-8, no byte order mark, LF; a CRLF inside a value is written as LF |
| Lines | KEY=value, upper snake case, no export, no spaces around =, no duplicates. penv pull adds a # penv:redacted KEY comment for each key we withhold. |
| Plain value | Unquoted |
#, ', a backtick or whitespace | Double quotes |
| Line breaks | Double quotes, each break as \n |
", \, $ or a function call | Single quotes, read literally. penv never expands a written value. |
A ' with any of those; a carriage return; a line break with " or \ | Refused as unwritable_value; read it with penv run |
The reader accepts more and warns with line and column, never the character.
| Reader outcome | Input |
|---|---|
| Read, with a warning | Byte order mark, CRLF endings, export prefix, spaces around =, lowercase name, duplicate key (the last wins), comment after a value, value spanning lines, unclosed quote, the escapes \r, \t, \" and \\ (decoded) |
| Dropped, with a warning | Unusable key name, line with no = |
Next: penv check.