envtidy 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envtidy/__init__.py +3 -0
- envtidy/cli.py +350 -0
- envtidy-0.2.0.dist-info/METADATA +123 -0
- envtidy-0.2.0.dist-info/RECORD +8 -0
- envtidy-0.2.0.dist-info/WHEEL +5 -0
- envtidy-0.2.0.dist-info/entry_points.txt +2 -0
- envtidy-0.2.0.dist-info/licenses/LICENSE +22 -0
- envtidy-0.2.0.dist-info/top_level.txt +1 -0
envtidy/__init__.py
ADDED
envtidy/cli.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""envtidy — keep your .env files honest.
|
|
2
|
+
|
|
3
|
+
Zero-dependency CLI that catches .env drift, generates sanitized
|
|
4
|
+
.env.example files, and finds env files at risk of being committed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Terminal colors (respects NO_COLOR and non-tty output)
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _use_color() -> bool:
|
|
24
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
25
|
+
return False
|
|
26
|
+
if os.environ.get("FORCE_COLOR") is not None:
|
|
27
|
+
return True
|
|
28
|
+
return sys.stdout.isatty()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class C:
|
|
32
|
+
enabled = _use_color()
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def _wrap(cls, code: str, text: str) -> str:
|
|
36
|
+
return f"\033[{code}m{text}\033[0m" if cls.enabled else text
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def red(cls, t: str) -> str:
|
|
40
|
+
return cls._wrap("31", t)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def green(cls, t: str) -> str:
|
|
44
|
+
return cls._wrap("32", t)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def yellow(cls, t: str) -> str:
|
|
48
|
+
return cls._wrap("33", t)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def cyan(cls, t: str) -> str:
|
|
52
|
+
return cls._wrap("36", t)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def dim(cls, t: str) -> str:
|
|
56
|
+
return cls._wrap("2", t)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def bold(cls, t: str) -> str:
|
|
60
|
+
return cls._wrap("1", t)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Dotenv parsing
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# KEY=VALUE with optional `export ` prefix; permissive on key charset
|
|
68
|
+
_LINE_RE = re.compile(
|
|
69
|
+
r"""^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?P<value>.*)$"""
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_env(path: Path) -> dict[str, str]:
|
|
74
|
+
"""Parse a dotenv file into an ordered {key: raw_value} dict."""
|
|
75
|
+
entries: dict[str, str] = {}
|
|
76
|
+
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
77
|
+
line = raw.strip()
|
|
78
|
+
if not line or line.startswith("#"):
|
|
79
|
+
continue
|
|
80
|
+
m = _LINE_RE.match(raw)
|
|
81
|
+
if not m:
|
|
82
|
+
continue
|
|
83
|
+
value = m.group("value").strip()
|
|
84
|
+
# Strip surrounding quotes and trailing unquoted comments
|
|
85
|
+
if value[:1] in ("'", '"') and value[-1:] == value[:1] and len(value) >= 2:
|
|
86
|
+
value = value[1:-1]
|
|
87
|
+
else:
|
|
88
|
+
value = value.split(" #", 1)[0].rstrip()
|
|
89
|
+
entries[m.group("key")] = value
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# check — compare .env against .env.example
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
EXAMPLE_SUFFIXES = (".example", ".sample", ".template", ".dist")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_example(env_path: Path) -> Path | None:
|
|
101
|
+
for suffix in EXAMPLE_SUFFIXES:
|
|
102
|
+
candidate = env_path.with_name(env_path.name + suffix)
|
|
103
|
+
if candidate.is_file():
|
|
104
|
+
return candidate
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
109
|
+
env_path = Path(args.env)
|
|
110
|
+
if not env_path.is_file():
|
|
111
|
+
print(f"{C.red('error:')} {env_path} not found", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
example_path = Path(args.example) if args.example else find_example(env_path)
|
|
115
|
+
if example_path is None or not example_path.is_file():
|
|
116
|
+
print(
|
|
117
|
+
f"{C.red('error:')} no example file found next to {env_path} "
|
|
118
|
+
f"(tried {', '.join(env_path.name + s for s in EXAMPLE_SUFFIXES)})",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
)
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
env = parse_env(env_path)
|
|
124
|
+
example = parse_env(example_path)
|
|
125
|
+
|
|
126
|
+
missing = [k for k in example if k not in env]
|
|
127
|
+
extra = [k for k in env if k not in example]
|
|
128
|
+
empty = [k for k, v in env.items() if v == "" and k in example]
|
|
129
|
+
|
|
130
|
+
print(C.bold(f"envtidy check {C.dim(f'{env_path} vs {example_path}')}"))
|
|
131
|
+
issues = 0
|
|
132
|
+
for key in missing:
|
|
133
|
+
issues += 1
|
|
134
|
+
print(f" {C.red('missing')} {key} {C.dim('(in example, not in env)')}")
|
|
135
|
+
for key in extra:
|
|
136
|
+
issues += 1
|
|
137
|
+
print(f" {C.yellow('extra')} {key} {C.dim('(in env, not in example)')}")
|
|
138
|
+
for key in empty:
|
|
139
|
+
issues += 1
|
|
140
|
+
print(f" {C.yellow('empty')} {key} {C.dim('(declared but has no value)')}")
|
|
141
|
+
|
|
142
|
+
if issues == 0:
|
|
143
|
+
print(f" {C.green('ok')} {len(env)} keys in sync")
|
|
144
|
+
return 0
|
|
145
|
+
print(
|
|
146
|
+
f"\n{C.bold(str(issues))} issue{'s' if issues != 1 else ''} found "
|
|
147
|
+
f"({len(missing)} missing, {len(extra)} extra, {len(empty)} empty)"
|
|
148
|
+
)
|
|
149
|
+
if extra and not args.no_hint:
|
|
150
|
+
print(C.dim("hint: run `envtidy sync` to update the example file"))
|
|
151
|
+
return 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# sync — generate/update a sanitized .env.example from .env
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def sanitize_lines(env_path: Path) -> list[str]:
|
|
160
|
+
"""Rewrite dotenv content with values stripped, preserving structure."""
|
|
161
|
+
out: list[str] = []
|
|
162
|
+
for raw in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
163
|
+
stripped = raw.strip()
|
|
164
|
+
if not stripped or stripped.startswith("#"):
|
|
165
|
+
out.append(raw)
|
|
166
|
+
continue
|
|
167
|
+
m = _LINE_RE.match(raw)
|
|
168
|
+
if m:
|
|
169
|
+
prefix = "export " if raw.lstrip().startswith("export ") else ""
|
|
170
|
+
out.append(f"{prefix}{m.group('key')}=")
|
|
171
|
+
else:
|
|
172
|
+
out.append(raw)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
177
|
+
env_path = Path(args.env)
|
|
178
|
+
if not env_path.is_file():
|
|
179
|
+
print(f"{C.red('error:')} {env_path} not found", file=sys.stderr)
|
|
180
|
+
return 2
|
|
181
|
+
|
|
182
|
+
example_path = (
|
|
183
|
+
Path(args.example)
|
|
184
|
+
if args.example
|
|
185
|
+
else (find_example(env_path) or env_path.with_name(env_path.name + ".example"))
|
|
186
|
+
)
|
|
187
|
+
content = "\n".join(sanitize_lines(env_path)) + "\n"
|
|
188
|
+
|
|
189
|
+
if args.dry_run:
|
|
190
|
+
sys.stdout.write(content)
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
existed = example_path.exists()
|
|
194
|
+
example_path.write_text(content, encoding="utf-8")
|
|
195
|
+
verb = "updated" if existed else "created"
|
|
196
|
+
n_keys = len(parse_env(env_path))
|
|
197
|
+
print(f"{C.green(verb)} {example_path} ({n_keys} keys, values stripped)")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# scan — find env files at risk of being committed
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
SKIP_DIRS = {
|
|
206
|
+
".git", "node_modules", ".venv", "venv", "__pycache__",
|
|
207
|
+
".tox", ".mypy_cache", "dist", "build", ".next", "target",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_ENV_NAME_RE = re.compile(r"^\.env(\..+)?$|^.+\.env$")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_env_file(name: str) -> bool:
|
|
214
|
+
if any(name.endswith(s) for s in EXAMPLE_SUFFIXES):
|
|
215
|
+
return False
|
|
216
|
+
return bool(_ENV_NAME_RE.match(name))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _git(root: Path, *argv: str) -> subprocess.CompletedProcess:
|
|
220
|
+
return subprocess.run(
|
|
221
|
+
["git", "-C", str(root), *argv],
|
|
222
|
+
capture_output=True,
|
|
223
|
+
text=True,
|
|
224
|
+
check=False,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def env_files_in_history(root: Path) -> set[str]:
|
|
229
|
+
"""Paths (relative to scan root) of env files ever added to git history."""
|
|
230
|
+
prefix = _git(root, "rev-parse", "--show-prefix").stdout.strip()
|
|
231
|
+
log = _git(
|
|
232
|
+
root, "log", "--all", "--diff-filter=A", "--name-only", "--pretty=format:"
|
|
233
|
+
)
|
|
234
|
+
if log.returncode != 0:
|
|
235
|
+
return set()
|
|
236
|
+
hits: set[str] = set()
|
|
237
|
+
for line in log.stdout.splitlines():
|
|
238
|
+
line = line.strip()
|
|
239
|
+
if not line or not is_env_file(os.path.basename(line)):
|
|
240
|
+
continue
|
|
241
|
+
# git log paths are relative to the repo top level; keep only those
|
|
242
|
+
# under the scan root and re-relativize them to it
|
|
243
|
+
if prefix:
|
|
244
|
+
if not line.startswith(prefix):
|
|
245
|
+
continue
|
|
246
|
+
line = line[len(prefix):]
|
|
247
|
+
hits.add(line)
|
|
248
|
+
return hits
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
252
|
+
root = Path(args.dir).resolve()
|
|
253
|
+
if not root.is_dir():
|
|
254
|
+
print(f"{C.red('error:')} {root} is not a directory", file=sys.stderr)
|
|
255
|
+
return 2
|
|
256
|
+
|
|
257
|
+
env_files: list[Path] = []
|
|
258
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
259
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
260
|
+
for name in filenames:
|
|
261
|
+
if is_env_file(name):
|
|
262
|
+
env_files.append(Path(dirpath) / name)
|
|
263
|
+
|
|
264
|
+
print(C.bold(f"envtidy scan {C.dim(str(root))}"))
|
|
265
|
+
in_git = _git(root, "rev-parse", "--is-inside-work-tree").returncode == 0
|
|
266
|
+
history = env_files_in_history(root) if in_git else set()
|
|
267
|
+
|
|
268
|
+
if not env_files and not history:
|
|
269
|
+
print(f" {C.green('ok')} no env files found")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
issues = 0
|
|
273
|
+
seen: set[str] = set()
|
|
274
|
+
for path in sorted(env_files):
|
|
275
|
+
rel = str(path.relative_to(root))
|
|
276
|
+
seen.add(rel)
|
|
277
|
+
if not in_git:
|
|
278
|
+
print(f" {C.cyan('found')} {rel} {C.dim('(not a git repo)')}")
|
|
279
|
+
continue
|
|
280
|
+
tracked = _git(root, "ls-files", "--error-unmatch", rel).returncode == 0
|
|
281
|
+
if tracked:
|
|
282
|
+
issues += 1
|
|
283
|
+
print(f" {C.red('TRACKED')} {rel} {C.dim('(committed to git — rotate these secrets)')}")
|
|
284
|
+
continue
|
|
285
|
+
if rel in history:
|
|
286
|
+
issues += 1
|
|
287
|
+
print(f" {C.red('HISTORY')} {rel} {C.dim('(untracked now, but still in git history — rotate + scrub)')}")
|
|
288
|
+
continue
|
|
289
|
+
ignored = _git(root, "check-ignore", "-q", rel).returncode == 0
|
|
290
|
+
if ignored:
|
|
291
|
+
print(f" {C.green('ignored')} {rel}")
|
|
292
|
+
else:
|
|
293
|
+
issues += 1
|
|
294
|
+
print(f" {C.yellow('exposed')} {rel} {C.dim('(not in .gitignore — one `git add .` from leaking)')}")
|
|
295
|
+
|
|
296
|
+
# env files deleted from the working tree but still recoverable from history
|
|
297
|
+
for rel in sorted(history - seen):
|
|
298
|
+
if _git(root, "ls-files", "--error-unmatch", rel).returncode == 0:
|
|
299
|
+
continue # currently tracked under a path outside the walk (skipped dir)
|
|
300
|
+
issues += 1
|
|
301
|
+
print(f" {C.red('HISTORY')} {rel} {C.dim('(deleted, but still in git history — rotate + scrub)')}")
|
|
302
|
+
|
|
303
|
+
if issues:
|
|
304
|
+
print(f"\n{C.bold(str(issues))} file{'s' if issues != 1 else ''} at risk")
|
|
305
|
+
if any(rel in history for rel in seen) or history - seen:
|
|
306
|
+
print(C.dim("hint: scrub history with `git filter-repo --sensitive-data-removal --invert-paths --path <file>`"))
|
|
307
|
+
return 1
|
|
308
|
+
print(f"\n{C.green('all clear')} — every env file is gitignored and absent from git history")
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# entry point
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
318
|
+
parser = argparse.ArgumentParser(
|
|
319
|
+
prog="envtidy",
|
|
320
|
+
description="Keep your .env files honest: catch drift, sync examples, find leaks.",
|
|
321
|
+
)
|
|
322
|
+
parser.add_argument("--version", action="version", version=f"envtidy {__version__}")
|
|
323
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
324
|
+
|
|
325
|
+
p_check = sub.add_parser("check", help="compare .env against .env.example")
|
|
326
|
+
p_check.add_argument("env", nargs="?", default=".env", help="env file (default: .env)")
|
|
327
|
+
p_check.add_argument("--example", help="example file (default: auto-detect)")
|
|
328
|
+
p_check.add_argument("--no-hint", action="store_true", help=argparse.SUPPRESS)
|
|
329
|
+
p_check.set_defaults(func=cmd_check)
|
|
330
|
+
|
|
331
|
+
p_sync = sub.add_parser("sync", help="generate .env.example from .env (values stripped)")
|
|
332
|
+
p_sync.add_argument("env", nargs="?", default=".env", help="env file (default: .env)")
|
|
333
|
+
p_sync.add_argument("--example", help="output file (default: <env>.example)")
|
|
334
|
+
p_sync.add_argument("--dry-run", action="store_true", help="print result instead of writing")
|
|
335
|
+
p_sync.set_defaults(func=cmd_sync)
|
|
336
|
+
|
|
337
|
+
p_scan = sub.add_parser("scan", help="find env files at risk of being committed")
|
|
338
|
+
p_scan.add_argument("dir", nargs="?", default=".", help="directory to scan (default: .)")
|
|
339
|
+
p_scan.set_defaults(func=cmd_scan)
|
|
340
|
+
|
|
341
|
+
return parser
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main(argv: list[str] | None = None) -> int:
|
|
345
|
+
args = build_parser().parse_args(argv)
|
|
346
|
+
return args.func(args)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
sys.exit(main())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envtidy
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Keep your .env files honest: catch drift, sync examples, find leaks. Zero dependencies.
|
|
5
|
+
Author: Nidhi Sebastian
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nidhisebastian008/envtidy
|
|
8
|
+
Project-URL: Issues, https://github.com/nidhisebastian008/envtidy/issues
|
|
9
|
+
Keywords: dotenv,env,environment-variables,cli,devtools,secrets
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# envtidy
|
|
22
|
+
|
|
23
|
+
> Keep your `.env` files honest — catch drift, sync examples, find leaks.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
Every team has lived this bug: someone adds `REDIS_URL` to their local `.env`, forgets to update `.env.example`, and the next person to clone the repo burns an hour debugging a crash that was really just a missing variable. Or worse — someone's `staging.env` was never gitignored and ships to GitHub with live credentials inside.
|
|
30
|
+
|
|
31
|
+
**envtidy** is a tiny CLI that catches both. Pure Python stdlib, zero dependencies, single-purpose.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install git+https://github.com/nidhisebastian008/envtidy
|
|
39
|
+
# or
|
|
40
|
+
pip install git+https://github.com/nidhisebastian008/envtidy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### `envtidy check` — catch drift
|
|
46
|
+
|
|
47
|
+
Compares `.env` against `.env.example` (also auto-detects `.sample`, `.template`, `.dist`):
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ envtidy check
|
|
51
|
+
envtidy check .env vs .env.example
|
|
52
|
+
missing REDIS_URL (in example, not in env)
|
|
53
|
+
extra DEBUG (in env, not in example)
|
|
54
|
+
empty SMTP_HOST (declared but has no value)
|
|
55
|
+
|
|
56
|
+
3 issues found (1 missing, 1 extra, 1 empty)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Exits `1` when drift is found, `0` when clean — drop it straight into CI or a pre-commit hook:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
# .pre-commit-config.yaml
|
|
63
|
+
- repo: local
|
|
64
|
+
hooks:
|
|
65
|
+
- id: envtidy
|
|
66
|
+
name: envtidy check
|
|
67
|
+
entry: envtidy check
|
|
68
|
+
language: system
|
|
69
|
+
pass_filenames: false
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `envtidy sync` — regenerate the example file
|
|
73
|
+
|
|
74
|
+
Rewrites `.env.example` from your real `.env`, stripping every value but preserving comments, blank lines, and key order:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
$ envtidy sync
|
|
78
|
+
created .env.example (4 keys, values stripped)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Use `--dry-run` to preview without writing.
|
|
82
|
+
|
|
83
|
+
### `envtidy scan` — find files that leaked (or are about to)
|
|
84
|
+
|
|
85
|
+
Walks a directory tree, finds every env file, and checks each one against git — **including the full git history**. Deleting a committed `.env` doesn't remove it; anyone with a clone can still recover it. `scan` catches that:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
$ envtidy scan
|
|
89
|
+
envtidy scan ~/code/myapp
|
|
90
|
+
ignored .env
|
|
91
|
+
exposed staging.env (not in .gitignore — one `git add .` from leaking)
|
|
92
|
+
TRACKED api/.env.production (committed to git — rotate these secrets)
|
|
93
|
+
HISTORY old/.env.local (deleted, but still in git history — rotate + scrub)
|
|
94
|
+
|
|
95
|
+
3 files at risk
|
|
96
|
+
hint: scrub history with `git filter-repo --sensitive-data-removal --invert-paths --path <file>`
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- **ignored** — safely gitignored, never committed ✅
|
|
100
|
+
- **exposed** — exists but *not* gitignored; one `git add .` away from a leak
|
|
101
|
+
- **TRACKED** — committed right now; treat those secrets as compromised and rotate them
|
|
102
|
+
- **HISTORY** — not in the working tree or index anymore, but recoverable from git history; rotate the secrets *and* scrub the history
|
|
103
|
+
|
|
104
|
+
## Alternatives
|
|
105
|
+
|
|
106
|
+
Prior art exists — pick the right tool for your job:
|
|
107
|
+
|
|
108
|
+
- [dotenv-linter](https://github.com/dotenv-linter/dotenv-linter) — fast Rust linter for `.env` *style* (duplicate keys, ordering, quoting), with a `compare` command for key diffs. No git awareness, no example generation.
|
|
109
|
+
- [sync-dotenv](https://github.com/luqmanoop/sync-dotenv) — Node tool doing what `envtidy sync` does.
|
|
110
|
+
- [gitleaks](https://github.com/gitleaks/gitleaks) / trufflehog / detect-secrets — heavyweight *content-level* secret scanners with rules and entropy checks. Use them for full audits; use `envtidy scan` for the quick file-level answer to "are my env files safe in this repo?"
|
|
111
|
+
|
|
112
|
+
envtidy's niche: all three jobs (drift check, example sync, git-aware leak scan) in one zero-dependency CLI with no config.
|
|
113
|
+
|
|
114
|
+
## Details
|
|
115
|
+
|
|
116
|
+
- Understands `export KEY=value`, quoted values, inline `# comments`
|
|
117
|
+
- Respects [`NO_COLOR`](https://no-color.org/); colors auto-disable when piped
|
|
118
|
+
- Skips `node_modules`, `.venv`, `.git`, build dirs while scanning
|
|
119
|
+
- Exit codes: `0` clean · `1` issues found · `2` usage error
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
envtidy/__init__.py,sha256=j6UBI8eB36OxpRknwzIoC_PMcRDmRG0K1Y7r0mh6Poc,70
|
|
2
|
+
envtidy/cli.py,sha256=wUlxpWuBGWhNrg1k0CvaCII3mkQlMLMpK6ZpLB3jmNg,12281
|
|
3
|
+
envtidy-0.2.0.dist-info/licenses/LICENSE,sha256=BOTIWnyCicrtFp_VI25capTgqjUC8Dup_4kztoeVtHU,1073
|
|
4
|
+
envtidy-0.2.0.dist-info/METADATA,sha256=Pf-idGEhCDEpOVRtFJbW14U1PRE8IUOgIHbEImgaubQ,4826
|
|
5
|
+
envtidy-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
envtidy-0.2.0.dist-info/entry_points.txt,sha256=N4SQkg7wT7m8YPeOQMDkiP0cVk2NB5ayV0GXyz2qfWQ,45
|
|
7
|
+
envtidy-0.2.0.dist-info/top_level.txt,sha256=CHWVGL56RU3gyZKsoqkXWxZWyx9BWX-2wOud-0aBjv4,8
|
|
8
|
+
envtidy-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nidhi Sebastian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
envtidy
|