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