v0.163.0
  1"""`plain env` — encrypted values in committed `.env` files.
  2
  3A value written as `KEY=encrypted:<token>` is decrypted by the dotenv loader
  4with `DEV_ENV_KEY`. These commands generate that key, and read and write
  5encrypted values. The loader itself lives in `plain.dev.dotenv`.
  6
  7`cli` reaches the top-level `plain` CLI through the `plain.cli` entry point
  8group, so it runs without `plain.runtime.setup()` — it's the command you reach
  9for exactly when loading the app would fail: a fresh clone with no key, or a
 10rotation to a new one. It loads the `.env` ladder itself, without decrypting.
 11"""
 12
 13from __future__ import annotations
 14
 15import os
 16import re
 17import subprocess
 18import sys
 19from pathlib import Path
 20
 21import click
 22from plain.exceptions import ImproperlyConfigured
 23
 24from .dotenv import (
 25    ENV_KEY_VAR,
 26    bound_sources,
 27    decrypt_env_binding,
 28    dotenv_ladder,
 29    encrypt_env_value,
 30    find_env_binding,
 31    generate_env_key,
 32    load_dotenv_files,
 33)
 34
 35_ENV_KEY_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
 36
 37# Both commands take the same --file option.
 38_env_file_option = click.option(
 39    "--file",
 40    "-f",
 41    "file_path",
 42    default=None,
 43    help="The .env file to use. Defaults to .env.{PLAIN_ENV}, normally .env.dev.",
 44)
 45
 46
 47def prepare_env() -> None:
 48    """Load the `.env` ladder for these commands, without decrypting anything.
 49
 50    `plain env` edits `.env.dev` by default, so it loads the dev ladder to find
 51    DEV_ENV_KEY. Nothing is decrypted on the way in: existing values may be
 52    under a key you don't have yet, or under the old key you're rotating away
 53    from, and neither should stop you from running these commands.
 54    """
 55    os.environ.setdefault("PLAIN_ENV", "dev")
 56    try:
 57        load_dotenv_files(decrypt=False)
 58    except ImproperlyConfigured as e:
 59        # These commands run without app setup, so nothing above us renders an
 60        # ImproperlyConfigured — and a traceback is the wrong answer for a
 61        # problem in a `.env` file.
 62        raise click.ClickException(str(e)) from e
 63
 64
 65@click.group()
 66def cli() -> None:
 67    """Encrypted values in committed .env files."""
 68    prepare_env()
 69
 70
 71@cli.command()
 72def key() -> None:
 73    """Generate a new DEV_ENV_KEY (printed alone on stdout, so it can be piped)."""
 74    click.echo(generate_env_key())
 75    click.secho(
 76        f"Store this key somewhere durable (1Password, your keychain) and bind it as {ENV_KEY_VAR}.\n"
 77        f'For example, in .env.dev.local: {ENV_KEY_VAR}=$(op read "op://Vault/item/field")',
 78        dim=True,
 79        err=True,
 80    )
 81
 82
 83@cli.command("set")
 84@click.argument("name")
 85@click.argument("value", required=False)
 86@_env_file_option
 87def set_value(name: str, value: str | None, file_path: str | None) -> None:
 88    """Encrypt VALUE and write NAME=encrypted:... into the file.
 89
 90    With no VALUE, the value is read from stdin (so multi-line secrets work:
 91    `plain env set GITHUB_APP_PRIVATE_KEY < key.pem`).
 92
 93    Values already in the file are never read, so `DEV_ENV_KEY=<new key>
 94    plain env set ...` re-encrypts one value under a new key.
 95    """
 96    from cryptography.fernet import Fernet
 97
 98    _validate_name(name)
 99    env_key = _require_env_key()
100
101    # Check the key by itself, so a problem with the value can't be reported
102    # as a bad key.
103    try:
104        Fernet(env_key.encode("ascii"))
105    except ValueError as e:
106        raise click.ClickException(
107            f"{ENV_KEY_VAR} is not a valid key (expected a 44 character key from `plain env key`)"
108        ) from e
109
110    if value is None:
111        if _stdin_is_a_tty():
112            raise click.ClickException(
113                "Pass VALUE as an argument, or pipe it in: plain env set NAME < file"
114            )
115        try:
116            value = sys.stdin.read().removesuffix("\n")
117        except UnicodeDecodeError as e:
118            raise click.ClickException("VALUE is not valid UTF-8 text") from e
119
120    binding_line = f"{name}={encrypt_env_value(value, env_key)}"
121
122    # newline="" keeps the file's line endings exactly as they are
123    path = _env_file_path(file_path)
124    content = path.read_text(encoding="utf-8", newline="") if path.exists() else ""
125
126    binding = find_env_binding(content, name, source=path)
127    if binding:
128        content = (
129            content[: binding.key_start] + binding_line + content[binding.value_end :]
130        )
131    else:
132        line_ending = "\r\n" if "\r\n" in content else "\n"
133        if content and not content.endswith("\n"):
134            content += line_ending
135        content += binding_line + line_ending
136
137    path.write_text(content, encoding="utf-8", newline="")
138    click.echo(f"Wrote {binding_line} to {path}")
139
140    if _is_gitignored(path):
141        click.secho(
142            f"Warning: {path} is gitignored, so this value will not be committed. "
143            "Encrypted values are meant to be committed — if .gitignore has a `.env*` "
144            "rule, replace it with `.env.local` and `.env.*.local`.",
145            fg="yellow",
146            err=True,
147        )
148
149    _warn_if_shadowed(name, path)
150
151
152@cli.command()
153@click.argument("name")
154@_env_file_option
155def get(name: str, file_path: str | None) -> None:
156    """Decrypt NAME from the file and print the plaintext to stdout."""
157    path = _env_file_path(file_path)
158    if not path.exists():
159        raise click.ClickException(f"{path} does not exist")
160
161    binding = find_env_binding(path.read_text(encoding="utf-8"), name, source=path)
162    if binding is None:
163        raise click.ClickException(f"{name} is not set in {path}")
164
165    if not binding.encrypted:
166        click.secho(
167            f"{name} in {path} is not encrypted (printing it as written)",
168            fg="yellow",
169            err=True,
170        )
171        click.echo(binding.value)
172        return
173
174    try:
175        plaintext = decrypt_env_binding(binding)
176    except ImproperlyConfigured as e:
177        raise click.ClickException(str(e)) from e
178
179    click.echo(plaintext)
180
181
182def _env_file_path(file_path: str | None) -> Path:
183    return Path(file_path or f".env.{os.environ.get('PLAIN_ENV', 'dev')}")
184
185
186def _validate_name(name: str) -> None:
187    if not _ENV_KEY_NAME_RE.fullmatch(name):
188        raise click.ClickException(
189            f"{name!r} is not a valid environment variable name "
190            "(letters, digits and underscores, not starting with a digit)"
191        )
192
193
194def _require_env_key() -> str:
195    """The DEV_ENV_KEY already loaded into the environment, or a clear error."""
196    env_key = os.environ.get(ENV_KEY_VAR, "")
197    if env_key:
198        return env_key
199
200    if ENV_KEY_VAR in os.environ:
201        raise click.ClickException(
202            f"{ENV_KEY_VAR} is set to an empty value. If it is bound with a command "
203            f'like {ENV_KEY_VAR}=$(op read "op://..."), that command failed — run it '
204            "yourself to see why."
205        )
206
207    raise click.ClickException(
208        f"{ENV_KEY_VAR} is not set. Generate one with `plain env key`, then bind it "
209        "in your environment (see the plain.dev README)."
210    )
211
212
213def _warn_if_shadowed(name: str, target: Path) -> None:
214    """Warn when something that loads before `target` already binds `name`.
215
216    The value would be written and committed, and then quietly ignored on this
217    machine, because the first file to bind a key wins.
218    """
219    if name not in os.environ:
220        return
221
222    source = bound_sources.get(name)
223    if source is None:
224        # Not bound by any file, so it came from the shell — which always wins.
225        where = "your shell environment"
226    elif _ladder_rank(source) < _ladder_rank(target):
227        where = str(source)
228    else:
229        return
230
231    click.secho(
232        f"Warning: {name} is also set in {where}, which outranks {target}; "
233        "the encrypted value will not be used on this machine.",
234        fg="yellow",
235        err=True,
236    )
237
238
239def _ladder_rank(path: Path) -> int:
240    """Where a file sits in the load order — the lower the rank, the earlier it wins."""
241    ladder = dotenv_ladder(os.environ.get("PLAIN_ENV", ""))
242    if str(path) in ladder:
243        return ladder.index(str(path))
244    return len(ladder)  # a file that isn't loaded at all, so everything outranks it
245
246
247def _stdin_is_a_tty() -> bool:
248    return sys.stdin.isatty()
249
250
251def _is_gitignored(path: Path) -> bool:
252    # Drop GIT_* from the environment for the same reason `_run_git` in
253    # plain/dev/postgres/identity.py does: a `plain` command run from a git
254    # hook would otherwise resolve against the hook's repository.
255    env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
256    try:
257        result = subprocess.run(
258            ["git", "check-ignore", "-q", str(path)],
259            capture_output=True,
260            check=False,
261            env=env,
262        )
263    except OSError:
264        return False
265    return result.returncode == 0