# envsync

envsync keeps `.env`, `.env.example`, staging and prod env files, docker-compose, GitHub Actions and Kubernetes ConfigMaps/Secrets from drifting apart. You describe your variables once in `envsync.toml`; envsync validates every environment against it, shows drift with secrets masked, regenerates `.env.example`, lints for hygiene problems, and generates typed settings loaders for Python and TypeScript.

## Features

- Dotenv parser with exact line errors: quotes, escapes, multiline values, `export`, comments, `${VAR}`, `$VAR`, `${VAR:-default}`, `${VAR-default}`.
- Extra sources: docker-compose `environment:`, GitHub Actions `env:` (workflow, job, step), Kubernetes ConfigMap/Secret (base64 decoded). Several files can be layered per environment.
- Typed schema: `str int float bool url email enum duration port json regex`, per-environment `required`, defaults, secret flag, deprecation and `renamed_to`.
- Commands: `check`, `diff`, `matrix`, `sync-example`, `lint`, `export` (json/shell/docker/k8s), `doc`, `codegen` (py/ts), `infer`.
- Secrets never printed: masked by schema flag, by name heuristics, by k8s `Secret` origin, and URL passwords are redacted.

## Install

```sh
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
```

Python 3.11+. Dependencies: `click`, `pyyaml`.

## Quickstart

```sh
cd examples/app
envsync check          # exit 1 if any environment has errors
envsync matrix
envsync diff staging prod
envsync lint
envsync sync-example --check
```

`envsync` reads `envsync.toml` from the current directory (`-c PATH` to override). Environment file paths inside it are relative to the schema file.

## Usage

The `examples/app` directory is a shop app with dev (`.env.dev` + docker-compose), staging (dotenv), prod (ConfigMap + Secret) and ci (GitHub Actions), with planted drift. All output below is real.

### check

```
$ envsync check --env staging --env prod
staging  [FAIL]  env/staging.env
  error WORKERS: int: must be an integer (got 'abc')  (env/staging.env:8)
  error ADMIN_EMAIL: email: must be an email address (got 'ops@shop')  (env/staging.env:10)
  error SENTRY_DSN: required variable is missing
prod  [FAIL]  k8s/prod-configmap.yaml, k8s/prod-secret.yaml
  error REQUEST_TIMEOUT: duration: must be a duration like 500ms, 30s, 5m, 1h30m, 2d (got '30')  (k8s/prod-configmap.yaml)
  error SMTP_PASSWORD: required variable is missing

5 error(s), 0 warning(s) across 2 environment(s)
```

Options: `--env NAME` (repeatable), `--strict` (unknown vars become errors), `--format json`. Exit codes: 0 clean, 1 findings, 2 usage/schema error. Values of `secret` variables never appear in messages.

### matrix

```
$ envsync matrix
variable         dev         staging  prod     ci
---------------  ----------  -------  -------  ----------
APP_ENV          ok          ok       ok       ok
DEBUG            ok          ok       ok       -
PORT             ok          -        -        -
PUBLIC_BASE_URL  -           ok       ok       -
DATABASE_URL     ok          ok       ok       ok
REDIS_URL        ok          ok       ok       ok
REDIS_HOST       deprecated  -        -        deprecated
SECRET_KEY       ok          ok       ok       ok
LOG_LEVEL        ok          ok       ok       ok
REQUEST_TIMEOUT  ok          -        INVALID  -
WORKERS          ok          INVALID  ok       ok
ADMIN_EMAIL      -           INVALID  ok       -
SENTRY_DSN       -           MISSING  ok       -
SMTP_PASSWORD    ok          ok       MISSING  -
FEATURE_FLAGS    ok          -        ok       -
DEBUG_TOOLBAR    extra       -        -        -
```

`--format md|json` also available.

### diff

`diff A B` takes schema environment names or file paths. Exit code 1 when they differ.

```
$ envsync diff dev ci
--- dev
+++ ci
- DEBUG=true   (only in dev)
- DEBUG_TOOLBAR=1   (only in dev)
- FEATURE_FLAGS={"new_checkout": true}   (only in dev)
- PORT=8000   (only in dev)
- REQUEST_TIMEOUT=5s   (only in dev)
- SMTP_PASSWORD=********   (only in dev)
~ APP_ENV: dev -> ci
~ DATABASE_URL: ******** -> ********   (secret values differ)
~ LOG_LEVEL: debug -> warning
~ REDIS_URL: redis://localhost:6379/0 -> redis://localhost:6379/1
~ SECRET_KEY: ******** -> ********   (secret values differ)
~ WORKERS: 2 -> 1

6 only in dev, 0 only in ci, 6 different, 1 identical
```

### lint

Checks dotenv files from the schema plus the example file (or the files you pass). Rules: `duplicate-key`, `trailing-space`, `unquoted-space`, `lowercase-key`, `real-secret` (example files only: known token prefixes such as `AKIA`, `ghp_`, `sk_live_`, or a secret-looking name with a high-entropy value that is not an obvious placeholder), plus parse errors and `undefined-ref`.

```
$ envsync lint
.env.example:3: lowercase-key debug should be UPPER_SNAKE_CASE
.env.example:5: duplicate-key PORT already defined on line 4
.env.example:6: trailing-space trailing whitespace
.env.example:7: unquoted-space ADMIN_NAME has whitespace in an unquoted value; wrap it in quotes
.env.example:8: real-secret SECRET_KEY looks like a real secret (entropy 5.0); use a placeholder
.env.example:9: real-secret STRIPE_API_KEY looks like a real secret (entropy 4.5); use a placeholder

6 issue(s)
```

### sync-example

Regenerates the example file from the schema (descriptions, type/requirement comments, defaults, `example` values; secrets stay empty; deprecated vars omitted). It is a pure function of the schema, so running it twice changes nothing. `--check` exits 1 if the file is stale (use it in CI); `-o PATH` writes elsewhere.

```
$ envsync sync-example --check
.env.example is out of date; run `envsync sync-example`
$ envsync sync-example
wrote .env.example
```

Generated content:

```
# Generated by `envsync sync-example` from envsync.toml. Do not edit by hand.

# Which deployment this process belongs to
# (enum: dev|staging|prod|ci, required)
APP_ENV=

# Enable debug pages and verbose errors; never true in prod
# (bool, optional, default: false)
DEBUG=false
```

### export

`envsync export TARGET --format json|shell|docker|k8s` where TARGET is an environment or a file. `k8s` emits a ConfigMap plus a Secret (base64) for secret variables; `--name` and `--namespace` set metadata. Export prints real values, so treat the output as sensitive.

```
$ envsync export staging --format shell
export APP_ENV=staging
export DEBUG=false
export PUBLIC_BASE_URL=https://staging.shop.example.com
...
```

### doc

`envsync doc [-o FILE]` prints a Markdown table of every variable:

```
| Variable | Type | Required | Default | Secret | Description |
|---|---|---|---|---|---|
| `APP_ENV` | enum: dev\|staging\|prod\|ci | yes |  |  | Which deployment this process belongs to |
| `PUBLIC_BASE_URL` | url | staging, prod |  |  | Externally visible base URL used in emails and redirects |
| `DATABASE_URL` | url | yes |  | yes | Primary database connection string |
```

### codegen

`envsync codegen --lang py|ts [-o FILE]` writes a standalone typed loader (no envsync dependency). Python gives a frozen dataclass `Settings` and `load(env=os.environ)`; TypeScript gives an `interface Settings` and `loadSettings(env = process.env)`. Both collect every problem into one `SettingsError`. Durations become seconds (float/number); variables that are only required in some environments are optional in the type. The Python output passes `mypy --strict`; the TypeScript output runs under Node's type stripping.

```python
from settings import load
settings = load()
settings.workers       # int
settings.request_timeout  # float seconds
```

### infer

Drafts a schema from existing files (types guessed from values, `required` from presence across environments, secrets from names and URL passwords):

```
$ envsync infer dev=.env.dev staging=env/staging.env
[environments]
dev = ".env.dev"
staging = "env/staging.env"

[vars.DEBUG]
type = "bool"
required = true

[vars.PORT]
type = "port"
required = ["dev"]

[vars.DATABASE_URL]
type = "url"
required = true
secret = true
```

Join several files for one environment with commas: `dev=.env.dev,docker-compose.yml`. Review the draft, then add descriptions and tighten it.

## Configuration

See [docs/schema.md](docs/schema.md) for the full `envsync.toml` reference and [docs/design.md](docs/design.md) for how parsing, validation and drift detection work.

## Architecture

| Module | Role |
|---|---|
| `parser.py` | Dotenv dialect parser, interpolation, line errors |
| `sources.py` | Loads dotenv, compose, GitHub Actions and k8s files into one `LoadedEnv` |
| `validators.py` | One validator per type, value-free error messages |
| `schema.py` | `envsync.toml` loading and schema validation |
| `analysis.py` | `check`, `diff`, `matrix`, secret masking |
| `lint.py` | Hygiene rules and entropy-based secret detection |
| `render.py` | `sync-example`, `doc`, `export` |
| `codegen.py` | Python and TypeScript loader generation |
| `infer.py` | Schema drafting |
| `cli.py` | Click commands |

## Tests

```sh
.venv/bin/pytest
```

106 tests cover parser edge cases, interpolation, every validator, inference, diff/matrix, sync idempotency, generated code (imported, `mypy --strict`, executed under Node) and the CLI end to end.

## Limitations

- Compose and GitHub Actions values are taken literally; compose `${VAR}` interpolation is not resolved. GitHub `${{ ... }}` expressions are accepted without type checking.
- For compose files, variables from all services are merged (last wins); env-file references (`env_file:`) are not followed.
- Dotenv `${VAR}` resolves against earlier lines in the same file only, not the process environment.
- YAML scalars are read as strings, so `true` and `0644` are never reinterpreted.
- Entropy-based secret detection is a heuristic; it errs towards flagging only secret-looking names.
