envshield 4.2.0__tar.gz → 4.3.0__tar.gz

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.
Files changed (65) hide show
  1. {envshield-4.2.0 → envshield-4.3.0}/PKG-INFO +40 -15
  2. {envshield-4.2.0 → envshield-4.3.0}/README.md +39 -14
  3. envshield-4.3.0/envshield/__init__.py +1 -0
  4. {envshield-4.2.0 → envshield-4.3.0}/envshield/cli.py +49 -14
  5. {envshield-4.2.0 → envshield-4.3.0}/envshield/config/manager.py +5 -3
  6. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/exceptions.py +8 -0
  7. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/scanner.py +1 -1
  8. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/service_discovery.py +65 -6
  9. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_service_discovery.py +52 -0
  10. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/test_cli.py +83 -0
  11. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/test_config_manager.py +21 -1
  12. envshield-4.2.0/envshield/tests/test_c6_diff_aware_scanning.py → envshield-4.3.0/envshield/tests/test_diff_aware_scanning.py +1 -1
  13. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/test_service_cli.py +19 -0
  14. {envshield-4.2.0 → envshield-4.3.0}/envshield.egg-info/PKG-INFO +40 -15
  15. {envshield-4.2.0 → envshield-4.3.0}/envshield.egg-info/SOURCES.txt +1 -1
  16. {envshield-4.2.0 → envshield-4.3.0}/pyproject.toml +1 -1
  17. envshield-4.2.0/envshield/__init__.py +0 -1
  18. {envshield-4.2.0 → envshield-4.3.0}/LICENSE.md +0 -0
  19. {envshield-4.2.0 → envshield-4.3.0}/envshield/__main__.py +0 -0
  20. {envshield-4.2.0 → envshield-4.3.0}/envshield/config/__init.py +0 -0
  21. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/__init__.py +0 -0
  22. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/doctor.py +0 -0
  23. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/file_updater.py +0 -0
  24. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/generator.py +0 -0
  25. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/hooks_manager.py +0 -0
  26. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/importer.py +0 -0
  27. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/inspector.py +0 -0
  28. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/schema_manager.py +0 -0
  29. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/schema_types.py +0 -0
  30. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/service_manager.py +0 -0
  31. {envshield-4.2.0 → envshield-4.3.0}/envshield/core/setup_manager.py +0 -0
  32. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/__init__.py +0 -0
  33. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/_base.py +0 -0
  34. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/_deployment.py +0 -0
  35. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/_docker_compose.py +0 -0
  36. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/_dotenv.py +0 -0
  37. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/_kubernetes.py +0 -0
  38. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/_python.py +0 -0
  39. {envshield-4.2.0 → envshield-4.3.0}/envshield/parsers/factory.py +0 -0
  40. {envshield-4.2.0 → envshield-4.3.0}/envshield/state.py +0 -0
  41. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/__init__.py +0 -0
  42. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/conftest.py +0 -0
  43. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/__init__.py +0 -0
  44. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_doctor.py +0 -0
  45. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_file_updater.py +0 -0
  46. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_generator.py +0 -0
  47. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_importer.py +0 -0
  48. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_scanner_compliance.py +0 -0
  49. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_schema_manager.py +0 -0
  50. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_schema_types.py +0 -0
  51. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_service_manager.py +0 -0
  52. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/core/test_setup_manager.py +0 -0
  53. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/parsers/__init__.py +0 -0
  54. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/parsers/test_docker_compose_parser.py +0 -0
  55. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/parsers/test_dotenv_parser.py +0 -0
  56. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/parsers/test_kubernetes_parser.py +0 -0
  57. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/parsers/test_python_parser.py +0 -0
  58. {envshield-4.2.0 → envshield-4.3.0}/envshield/tests/test_git_utils.py +0 -0
  59. {envshield-4.2.0 → envshield-4.3.0}/envshield/utils/__init__.py +0 -0
  60. {envshield-4.2.0 → envshield-4.3.0}/envshield/utils/git_utils.py +0 -0
  61. {envshield-4.2.0 → envshield-4.3.0}/envshield.egg-info/dependency_links.txt +0 -0
  62. {envshield-4.2.0 → envshield-4.3.0}/envshield.egg-info/entry_points.txt +0 -0
  63. {envshield-4.2.0 → envshield-4.3.0}/envshield.egg-info/requires.txt +0 -0
  64. {envshield-4.2.0 → envshield-4.3.0}/envshield.egg-info/top_level.txt +0 -0
  65. {envshield-4.2.0 → envshield-4.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: EnvShield: A CLI for secure local environment management and secret prevention.
5
5
  Author-email: Rabbil Yasar Sajal <rabbilyasar@gmail.com>
6
6
  License: MIT License
@@ -84,12 +84,14 @@ It works the same way whether you have one repo with one `.env` file, or a monor
84
84
  - [Installation](#installation)
85
85
  - [Quick start](#quick-start)
86
86
  - [A single service](#a-single-service)
87
- - [A monorepo with multiple services](#a-monorepo-with-multiple-services)
87
+ - [A monorepo with multiple services (monorepo)](#a-monorepo-with-multiple-services)
88
88
  - [Core concept: the schema is the contract](#core-concept-the-schema-is-the-contract)
89
89
  - [Every field a variable can have](#every-field-a-variable-can-have)
90
90
  - [Conditional requirements (`requiredIf`)](#conditional-requirements-requiredif)
91
- - [Sharing variables across services (`extends`)](#sharing-variables-across-services-extends)
91
+ - [Sharing variables across services (monorepo, `extends`)](#sharing-variables-across-services-extends)
92
92
  - [Command reference](#command-reference)
93
+ - [Core commands](#core-commands)
94
+ - [Monorepo: managing multiple services (monorepo)](#monorepo-managing-multiple-services)
93
95
  - [Typed config code generation](#typed-config-code-generation)
94
96
  - [Validating deployment manifests](#validating-deployment-manifests)
95
97
  - [Secret scanning and git hooks](#secret-scanning-and-git-hooks)
@@ -138,22 +140,28 @@ envshield --version
138
140
 
139
141
  ### A single service
140
142
 
141
- The common case: one repo, one `.env` file.
143
+ The common case: one repo, one `.env` file. This is the whole workflow — nothing else in this README is required to get full value out of EnvShield.
142
144
 
143
145
  ```bash
144
146
  cd my-project
145
- envshield init # Detects your framework, creates env.schema.toml + envshield.yml
146
- envshield import .env # Or: seed the schema from an existing .env file
147
+ envshield init # Detects your framework and builds env.schema.toml from your real config
147
148
  envshield setup # Interactive wizard: fills in .env from the schema
148
149
  envshield generate --lang python # Generates a typed config.py (or config.ts for TypeScript)
149
150
  ```
150
151
 
152
+ `init` looks for a real config source first — an existing `.env`, `.env.example`, or a recognizable Python config module (`config/settings.py` and similar) — and builds the schema from its actual variables, classifying each as secret or not, with a suggested default and, where the value's shape is unambiguous, an inferred type. Only a genuinely fresh project with nothing to read yet falls back to a generic framework template:
153
+
154
+ ```
155
+ Found config/settings.py -- building your schema from its real variables.
156
+ ✓ Created/updated schema: env.schema.toml
157
+ ```
158
+
151
159
  `init` also offers to install a git pre-commit hook (secret scanning) and a post-merge hook (drift check after every `git pull`) — say yes unless you already have your own hook-management tooling (Husky, `pre-commit`, etc.), in which case EnvShield will detect it and won't clobber it (see [Secret scanning and git hooks](#secret-scanning-and-git-hooks)).
152
160
 
153
- If you already have a real `.env` with real values, `import` is usually the better starting point than `init`'s blank framework defaults — it reads your actual file and classifies each variable (secret or not, with a suggested default and, where the value's shape is unambiguous, a type) in seconds:
161
+ `envshield import <file>` does the same real-variable analysis `init` runs automatically, as its own command — reach for it later, when you want to re-import after adding new variables to your code, point at a file `init` wouldn't have found on its own, or add `--interactive` to confirm each classification by hand instead of accepting the automatic guess:
154
162
 
155
163
  ```bash
156
- envshield import .env
164
+ envshield import .env --interactive
157
165
  ```
158
166
 
159
167
  ```
@@ -166,8 +174,14 @@ Analyzing variables...
166
174
  - Inferred a type (int/port/bool/url/email) for 3 variable(s).
167
175
  ```
168
176
 
177
+ ---
178
+
179
+ **Everything above is the complete single-service story.** Everything below this point — multiple services, schema composition, deployment manifests with more than one container — is opt-in, and only relevant once your repo actually has more than one service. Skip straight to [Core concept: the schema is the contract](#core-concept-the-schema-is-the-contract) if that's not you yet.
180
+
169
181
  ### A monorepo with multiple services
170
182
 
183
+ *(Optional — skip this if you only have one service.)*
184
+
171
185
  If your repo has more than one service (an API, a web frontend, a worker), run `service discover` at the repo root instead of `init`:
172
186
 
173
187
  ```bash
@@ -300,6 +314,8 @@ With `FEATURE_X_ENABLED=false`, `FEATURE_X_API_KEY` is optional — `setup` won'
300
314
 
301
315
  ### Sharing variables across services (`extends`)
302
316
 
317
+ *(Monorepo-only — skip this if you have one service.)*
318
+
303
319
  A monorepo with ten services usually has five or six variables every single one of them needs — `LOG_LEVEL`, `SENTRY_DSN`, `DATADOG_API_KEY` — and copy-pasting the same `[LOG_LEVEL]` block into ten schema files is exactly the kind of drift EnvShield exists to prevent.
304
320
 
305
321
  Factor them into a shared base schema, and have each service extend it:
@@ -336,22 +352,21 @@ Loading `services/api/env.schema.toml` now transparently gives you `LOG_LEVEL`,
336
352
 
337
353
  ## Command reference
338
354
 
339
- Every command that takes `--service` also works without it: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.
355
+ ### Core commands
356
+
357
+ Everything a single-service project ever needs. `--service` shows up on most of these for the multi-service case (see below), but it's entirely optional until you actually have more than one service.
340
358
 
341
359
  | Command | What it does |
342
360
  |---|---|
343
- | `envshield init [--force/-f]` | Detects your framework, scaffolds `env.schema.toml` + `envshield.yml`, updates `.gitignore`, and offers to install git hooks. Auto-registers a root-level `docker-compose.yml` as the project's deployment manifest if it finds one. `--force` re-runs on a project that already has a config (with a confirmation before overwriting). |
344
- | `envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME]` | Converts an existing `.env` file or Python config module into `env.schema.toml`, classifying each variable as secret or not, suggesting a default, and inferring a type where the value's shape is unambiguous. `--interactive` confirms each classification with you instead of applying it silently. `--output` changes where the schema is written (defaults to `env.schema.toml`, or the target service's schema path with `--service`). |
361
+ | `envshield init [--force/-f]` | Detects your framework and builds `env.schema.toml` from a real config source if it finds one, otherwise a framework-aware template. Also scaffolds `envshield.yml`, updates `.gitignore`, and offers to install git hooks. Auto-registers a root-level `docker-compose.yml` as the project's deployment manifest if it finds one. `--force` re-runs on a project that already has a config (with a confirmation before overwriting). |
362
+ | `envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME]` | Runs the same real-variable analysis `init` does automatically, as its own command — for re-importing after your code gains new variables, pointing at a file `init` wouldn't have found, or adding `--interactive` to confirm each secret/type classification by hand instead of accepting the automatic guess. `--output` changes where the schema is written (defaults to `env.schema.toml`, or the target service's schema path with `--service`). |
345
363
  | `envshield check [file] [--service NAME] [--container NAME]` | Validates a local file (or, if omitted, the project's/service's default local file *and* its registered deployment manifest, if any) against the schema. `file` can be a plain `.env`, a Python config module, a docker-compose file, or a Kubernetes manifest. `--container` picks which service/container to check in a manifest that declares more than one (tried against `--service`'s name automatically first). Exits non-zero on any drift — safe to use as a CI gate. |
346
364
  | `envshield doctor [--fix] [--service NAME]` | Runs every health check at once (see below) and reports a summary. `--fix` interactively offers to fix whatever it can — re-running `init`, regenerating the template, installing the git hook, or running `setup` to fill in missing/invalid local values. Exits non-zero if anything's still broken afterward. |
347
365
  | `envshield setup [output_file] [--service NAME]` | Interactive onboarding wizard: walks through every variable that's missing, blank, or has an existing value the schema no longer allows, prompting with the variable's description, masking secret input, and offering a picker for `enum` fields. Leaves everything already correct untouched. |
348
- | `envshield schema sync [--service NAME]` | Regenerates `.env.example` from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). |
366
+ | `envshield schema sync [--service NAME]` | Regenerates `.env.example` from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). `import` already calls this automatically for you when it changes a project's/service's real schema, so you'll rarely need to run it by hand except after a manual schema edit. |
349
367
  | `envshield generate [output_file] [--lang/-l python\|typescript] [--force/-f] [--service NAME]` | Compiles the schema into a typed, validated config module. `--lang` is auto-detected from your project (Next.js/Vite/Node.js → TypeScript, everything else → Python) if omitted. Defaults to writing `config.py`/`config.ts`; `--force` overwrites an existing output file. See [Typed config code generation](#typed-config-code-generation). |
350
368
  | `envshield scan [paths...] [--staged] [--config/-c PATH] [--exclude/-e PATTERN]` [--service NAME] | Scans code for hardcoded secrets and for env vars used in code (`os.getenv`, `os.environ.get`, `process.env.X`) but never declared in the schema. `--staged` scans only what's staged for the next commit (what the pre-commit hook runs); `--exclude` (repeatable) adds glob patterns to skip, on top of whatever `secret_scanning.exclude_files` is set in `envshield.yml`. See [Secret scanning and git hooks](#secret-scanning-and-git-hooks). |
351
369
  | `envshield install-hook` | Installs both git hooks by hand, without going through `init`/`setup`/`service discover`'s interactive prompt. |
352
- | `envshield service list` | Lists every service currently configured in `envshield.yml`, with its schema and local file paths. |
353
- | `envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME]` | Registers one service by hand. `--local-file` is required when the service's real config isn't a dotenv file (e.g. a Python module) — see below. `--import` seeds the new service's schema from an existing config file in one step. `--deployment-manifest` is auto-detected (a compose file in the given directory, or the project root) if not given explicitly. |
354
- | `envshield service discover [root] [--yes/-y]` | Scans for service-like directories not already registered, and offers to add them — see [Quick start](#a-monorepo-with-multiple-services). `--yes` skips the interactive confirmation, for CI/scripting. |
355
370
  | `envshield --version` / `-v` | Prints the installed version and exits. |
356
371
 
357
372
  **Not using a `.env` file at all?** Some projects (a Flask app whose local config is a checked-in Python module, for example) don't use dotenv at all. Point `local_file` at it instead, and EnvShield reads and writes it as source code, not as a dotenv file — appending or patching only the specific assignments it owns, never touching anything else in the file:
@@ -363,6 +378,16 @@ services:
363
378
  local_file: athena/config/env_config.local.py
364
379
  ```
365
380
 
381
+ ### Monorepo: managing multiple services
382
+
383
+ *(Only relevant once your repo has more than one service — see [A monorepo with multiple services](#a-monorepo-with-multiple-services).)* Every core command above already accepts `--service`: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.
384
+
385
+ | Command | What it does |
386
+ |---|---|
387
+ | `envshield service list` | Lists every service currently configured in `envshield.yml`, with its schema and local file paths. |
388
+ | `envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME]` | Registers one service by hand. `--local-file` is required when the service's real config isn't a dotenv file (e.g. a Python module) — see above. `--import` seeds the new service's schema from an existing config file in one step. `--deployment-manifest` is auto-detected (a compose file in the given directory or the project root that actually declares this service) if not given explicitly. |
389
+ | `envshield service discover [root] [--yes/-y]` | Scans for service-like directories not already registered, and offers to add them — see [Quick start](#a-monorepo-with-multiple-services). `--yes` skips the interactive confirmation, for CI/scripting. |
390
+
366
391
  ---
367
392
 
368
393
  ## Typed config code generation
@@ -29,12 +29,14 @@ It works the same way whether you have one repo with one `.env` file, or a monor
29
29
  - [Installation](#installation)
30
30
  - [Quick start](#quick-start)
31
31
  - [A single service](#a-single-service)
32
- - [A monorepo with multiple services](#a-monorepo-with-multiple-services)
32
+ - [A monorepo with multiple services (monorepo)](#a-monorepo-with-multiple-services)
33
33
  - [Core concept: the schema is the contract](#core-concept-the-schema-is-the-contract)
34
34
  - [Every field a variable can have](#every-field-a-variable-can-have)
35
35
  - [Conditional requirements (`requiredIf`)](#conditional-requirements-requiredif)
36
- - [Sharing variables across services (`extends`)](#sharing-variables-across-services-extends)
36
+ - [Sharing variables across services (monorepo, `extends`)](#sharing-variables-across-services-extends)
37
37
  - [Command reference](#command-reference)
38
+ - [Core commands](#core-commands)
39
+ - [Monorepo: managing multiple services (monorepo)](#monorepo-managing-multiple-services)
38
40
  - [Typed config code generation](#typed-config-code-generation)
39
41
  - [Validating deployment manifests](#validating-deployment-manifests)
40
42
  - [Secret scanning and git hooks](#secret-scanning-and-git-hooks)
@@ -83,22 +85,28 @@ envshield --version
83
85
 
84
86
  ### A single service
85
87
 
86
- The common case: one repo, one `.env` file.
88
+ The common case: one repo, one `.env` file. This is the whole workflow — nothing else in this README is required to get full value out of EnvShield.
87
89
 
88
90
  ```bash
89
91
  cd my-project
90
- envshield init # Detects your framework, creates env.schema.toml + envshield.yml
91
- envshield import .env # Or: seed the schema from an existing .env file
92
+ envshield init # Detects your framework and builds env.schema.toml from your real config
92
93
  envshield setup # Interactive wizard: fills in .env from the schema
93
94
  envshield generate --lang python # Generates a typed config.py (or config.ts for TypeScript)
94
95
  ```
95
96
 
97
+ `init` looks for a real config source first — an existing `.env`, `.env.example`, or a recognizable Python config module (`config/settings.py` and similar) — and builds the schema from its actual variables, classifying each as secret or not, with a suggested default and, where the value's shape is unambiguous, an inferred type. Only a genuinely fresh project with nothing to read yet falls back to a generic framework template:
98
+
99
+ ```
100
+ Found config/settings.py -- building your schema from its real variables.
101
+ ✓ Created/updated schema: env.schema.toml
102
+ ```
103
+
96
104
  `init` also offers to install a git pre-commit hook (secret scanning) and a post-merge hook (drift check after every `git pull`) — say yes unless you already have your own hook-management tooling (Husky, `pre-commit`, etc.), in which case EnvShield will detect it and won't clobber it (see [Secret scanning and git hooks](#secret-scanning-and-git-hooks)).
97
105
 
98
- If you already have a real `.env` with real values, `import` is usually the better starting point than `init`'s blank framework defaults — it reads your actual file and classifies each variable (secret or not, with a suggested default and, where the value's shape is unambiguous, a type) in seconds:
106
+ `envshield import <file>` does the same real-variable analysis `init` runs automatically, as its own command — reach for it later, when you want to re-import after adding new variables to your code, point at a file `init` wouldn't have found on its own, or add `--interactive` to confirm each classification by hand instead of accepting the automatic guess:
99
107
 
100
108
  ```bash
101
- envshield import .env
109
+ envshield import .env --interactive
102
110
  ```
103
111
 
104
112
  ```
@@ -111,8 +119,14 @@ Analyzing variables...
111
119
  - Inferred a type (int/port/bool/url/email) for 3 variable(s).
112
120
  ```
113
121
 
122
+ ---
123
+
124
+ **Everything above is the complete single-service story.** Everything below this point — multiple services, schema composition, deployment manifests with more than one container — is opt-in, and only relevant once your repo actually has more than one service. Skip straight to [Core concept: the schema is the contract](#core-concept-the-schema-is-the-contract) if that's not you yet.
125
+
114
126
  ### A monorepo with multiple services
115
127
 
128
+ *(Optional — skip this if you only have one service.)*
129
+
116
130
  If your repo has more than one service (an API, a web frontend, a worker), run `service discover` at the repo root instead of `init`:
117
131
 
118
132
  ```bash
@@ -245,6 +259,8 @@ With `FEATURE_X_ENABLED=false`, `FEATURE_X_API_KEY` is optional — `setup` won'
245
259
 
246
260
  ### Sharing variables across services (`extends`)
247
261
 
262
+ *(Monorepo-only — skip this if you have one service.)*
263
+
248
264
  A monorepo with ten services usually has five or six variables every single one of them needs — `LOG_LEVEL`, `SENTRY_DSN`, `DATADOG_API_KEY` — and copy-pasting the same `[LOG_LEVEL]` block into ten schema files is exactly the kind of drift EnvShield exists to prevent.
249
265
 
250
266
  Factor them into a shared base schema, and have each service extend it:
@@ -281,22 +297,21 @@ Loading `services/api/env.schema.toml` now transparently gives you `LOG_LEVEL`,
281
297
 
282
298
  ## Command reference
283
299
 
284
- Every command that takes `--service` also works without it: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.
300
+ ### Core commands
301
+
302
+ Everything a single-service project ever needs. `--service` shows up on most of these for the multi-service case (see below), but it's entirely optional until you actually have more than one service.
285
303
 
286
304
  | Command | What it does |
287
305
  |---|---|
288
- | `envshield init [--force/-f]` | Detects your framework, scaffolds `env.schema.toml` + `envshield.yml`, updates `.gitignore`, and offers to install git hooks. Auto-registers a root-level `docker-compose.yml` as the project's deployment manifest if it finds one. `--force` re-runs on a project that already has a config (with a confirmation before overwriting). |
289
- | `envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME]` | Converts an existing `.env` file or Python config module into `env.schema.toml`, classifying each variable as secret or not, suggesting a default, and inferring a type where the value's shape is unambiguous. `--interactive` confirms each classification with you instead of applying it silently. `--output` changes where the schema is written (defaults to `env.schema.toml`, or the target service's schema path with `--service`). |
306
+ | `envshield init [--force/-f]` | Detects your framework and builds `env.schema.toml` from a real config source if it finds one, otherwise a framework-aware template. Also scaffolds `envshield.yml`, updates `.gitignore`, and offers to install git hooks. Auto-registers a root-level `docker-compose.yml` as the project's deployment manifest if it finds one. `--force` re-runs on a project that already has a config (with a confirmation before overwriting). |
307
+ | `envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME]` | Runs the same real-variable analysis `init` does automatically, as its own command — for re-importing after your code gains new variables, pointing at a file `init` wouldn't have found, or adding `--interactive` to confirm each secret/type classification by hand instead of accepting the automatic guess. `--output` changes where the schema is written (defaults to `env.schema.toml`, or the target service's schema path with `--service`). |
290
308
  | `envshield check [file] [--service NAME] [--container NAME]` | Validates a local file (or, if omitted, the project's/service's default local file *and* its registered deployment manifest, if any) against the schema. `file` can be a plain `.env`, a Python config module, a docker-compose file, or a Kubernetes manifest. `--container` picks which service/container to check in a manifest that declares more than one (tried against `--service`'s name automatically first). Exits non-zero on any drift — safe to use as a CI gate. |
291
309
  | `envshield doctor [--fix] [--service NAME]` | Runs every health check at once (see below) and reports a summary. `--fix` interactively offers to fix whatever it can — re-running `init`, regenerating the template, installing the git hook, or running `setup` to fill in missing/invalid local values. Exits non-zero if anything's still broken afterward. |
292
310
  | `envshield setup [output_file] [--service NAME]` | Interactive onboarding wizard: walks through every variable that's missing, blank, or has an existing value the schema no longer allows, prompting with the variable's description, masking secret input, and offering a picker for `enum` fields. Leaves everything already correct untouched. |
293
- | `envshield schema sync [--service NAME]` | Regenerates `.env.example` from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). |
311
+ | `envshield schema sync [--service NAME]` | Regenerates `.env.example` from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). `import` already calls this automatically for you when it changes a project's/service's real schema, so you'll rarely need to run it by hand except after a manual schema edit. |
294
312
  | `envshield generate [output_file] [--lang/-l python\|typescript] [--force/-f] [--service NAME]` | Compiles the schema into a typed, validated config module. `--lang` is auto-detected from your project (Next.js/Vite/Node.js → TypeScript, everything else → Python) if omitted. Defaults to writing `config.py`/`config.ts`; `--force` overwrites an existing output file. See [Typed config code generation](#typed-config-code-generation). |
295
313
  | `envshield scan [paths...] [--staged] [--config/-c PATH] [--exclude/-e PATTERN]` [--service NAME] | Scans code for hardcoded secrets and for env vars used in code (`os.getenv`, `os.environ.get`, `process.env.X`) but never declared in the schema. `--staged` scans only what's staged for the next commit (what the pre-commit hook runs); `--exclude` (repeatable) adds glob patterns to skip, on top of whatever `secret_scanning.exclude_files` is set in `envshield.yml`. See [Secret scanning and git hooks](#secret-scanning-and-git-hooks). |
296
314
  | `envshield install-hook` | Installs both git hooks by hand, without going through `init`/`setup`/`service discover`'s interactive prompt. |
297
- | `envshield service list` | Lists every service currently configured in `envshield.yml`, with its schema and local file paths. |
298
- | `envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME]` | Registers one service by hand. `--local-file` is required when the service's real config isn't a dotenv file (e.g. a Python module) — see below. `--import` seeds the new service's schema from an existing config file in one step. `--deployment-manifest` is auto-detected (a compose file in the given directory, or the project root) if not given explicitly. |
299
- | `envshield service discover [root] [--yes/-y]` | Scans for service-like directories not already registered, and offers to add them — see [Quick start](#a-monorepo-with-multiple-services). `--yes` skips the interactive confirmation, for CI/scripting. |
300
315
  | `envshield --version` / `-v` | Prints the installed version and exits. |
301
316
 
302
317
  **Not using a `.env` file at all?** Some projects (a Flask app whose local config is a checked-in Python module, for example) don't use dotenv at all. Point `local_file` at it instead, and EnvShield reads and writes it as source code, not as a dotenv file — appending or patching only the specific assignments it owns, never touching anything else in the file:
@@ -308,6 +323,16 @@ services:
308
323
  local_file: athena/config/env_config.local.py
309
324
  ```
310
325
 
326
+ ### Monorepo: managing multiple services
327
+
328
+ *(Only relevant once your repo has more than one service — see [A monorepo with multiple services](#a-monorepo-with-multiple-services).)* Every core command above already accepts `--service`: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.
329
+
330
+ | Command | What it does |
331
+ |---|---|
332
+ | `envshield service list` | Lists every service currently configured in `envshield.yml`, with its schema and local file paths. |
333
+ | `envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME]` | Registers one service by hand. `--local-file` is required when the service's real config isn't a dotenv file (e.g. a Python module) — see above. `--import` seeds the new service's schema from an existing config file in one step. `--deployment-manifest` is auto-detected (a compose file in the given directory or the project root that actually declares this service) if not given explicitly. |
334
+ | `envshield service discover [root] [--yes/-y]` | Scans for service-like directories not already registered, and offers to add them — see [Quick start](#a-monorepo-with-multiple-services). `--yes` skips the interactive confirmation, for CI/scripting. |
335
+
311
336
  ---
312
337
 
313
338
  ## Typed config code generation
@@ -0,0 +1 @@
1
+ __version__ = "4.3.0"
@@ -1,6 +1,6 @@
1
1
  # envshield/cli.py
2
2
  import os
3
- from typing import List, Optional
3
+ from typing import List, Optional, cast
4
4
 
5
5
  import questionary
6
6
  import typer
@@ -92,7 +92,7 @@ def init(
92
92
  help="Overwrite existing EnvShield configuration files.",
93
93
  ),
94
94
  ):
95
- """Initializes EnvShield with intelligent, framework-aware defaults."""
95
+ """Initializes EnvShield -- builds env.schema.toml from your real config if one is found, otherwise a framework-aware template."""
96
96
  console.print(
97
97
  Panel(
98
98
  "[bold cyan]Welcome to EnvShield! Setting up your secure foundation...[/bold cyan]",
@@ -130,7 +130,20 @@ def init(
130
130
  )
131
131
 
132
132
  project_name = os.path.basename(os.getcwd())
133
- schema_content = config_manager.generate_default_schema_content(project_type)
133
+
134
+ # Prefer building the schema from a real, already-existing config
135
+ # source over a generic framework template -- a fixed template can
136
+ # only ever guess at your actual variables.
137
+ config_source = service_discovery.find_config_source(".")
138
+ used_real_source = bool(config_source)
139
+ if config_source:
140
+ console.print(
141
+ f"Found [bold yellow]{config_source}[/bold yellow] -- building your schema from its real variables."
142
+ )
143
+ schema_content = importer.generate_schema_from_file(config_source, interactive=False)
144
+ else:
145
+ schema_content = config_manager.generate_default_schema_content(project_type)
146
+
134
147
  config_manager.write_file(
135
148
  config_manager.SCHEMA_FILE_NAME,
136
149
  schema_content,
@@ -167,9 +180,15 @@ def init(
167
180
  raise typer.Exit()
168
181
 
169
182
  console.print("\n[bold green]✨ Setup Complete! ✨[/bold green]")
170
- console.print(
171
- "Your project is now protected. Define your variables in 'env.schema.toml'."
172
- )
183
+ if used_real_source:
184
+ console.print(
185
+ "Your project is now protected. Review 'env.schema.toml' -- "
186
+ "it was built from your real config, but double-check the secret/type guesses."
187
+ )
188
+ else:
189
+ console.print(
190
+ "Your project is now protected. Define your variables in 'env.schema.toml'."
191
+ )
173
192
  console.print("\n[bold cyan]Next step:[/bold cyan]")
174
193
  console.print(" envshield setup # Configure your local environment")
175
194
 
@@ -434,7 +453,8 @@ def generate(
434
453
  )
435
454
  raise typer.Exit()
436
455
 
437
- schema = config_manager.load_schema(service_name=service)
456
+ resolved_service = cast(Optional[str], service_manager.resolve_service(service))
457
+ schema = config_manager.load_schema(service_name=resolved_service)
438
458
  content = generator.generate_config(schema, lang=resolved_lang)
439
459
 
440
460
  with open(resolved_output, "w") as f:
@@ -481,6 +501,11 @@ def scan(
481
501
  ):
482
502
  """Scans files for hardcoded secrets and undeclared variables."""
483
503
  try:
504
+ if service:
505
+ # Validate eagerly for a consistent "Available: ..." error --
506
+ # run_scan's own service_name=None path means "check every
507
+ # configured service", so this only fires for an explicit name.
508
+ service_manager.resolve_service(service)
484
509
  scanner.run_scan(
485
510
  paths=paths,
486
511
  staged_only=staged,
@@ -537,11 +562,8 @@ def import_command(
537
562
  try:
538
563
  # If service is specified, use that service's schema path
539
564
  if service:
540
- resolved_output = config_manager.get_service_schema_path(service)
541
- if not resolved_output:
542
- console.print(f"[bold red]Error:[/bold red] Service '{service}' not found.")
543
- raise typer.Exit(code=1)
544
- output = resolved_output
565
+ service_manager.resolve_service(service)
566
+ output = cast(str, config_manager.get_service_schema_path(service))
545
567
 
546
568
  if os.path.exists(output) and not force and not interactive:
547
569
  console.print(
@@ -565,6 +587,13 @@ def import_command(
565
587
  console.print(
566
588
  f"\nSuccessfully generated schema at [bold cyan]{output}[/bold cyan]"
567
589
  )
590
+
591
+ # Keep the tracked template (.env.example) in sync with the schema
592
+ # we just (re)wrote -- only when `output` is the project's/service's
593
+ # real configured schema path, not some arbitrary --output destination
594
+ # there's no template mapping for.
595
+ if service or output == config_manager.SCHEMA_FILE_NAME:
596
+ schema_manager.sync_schema(service_name=service)
568
597
  except EnvShieldException as e:
569
598
  console.print(f"[bold red]Error:[/bold red] {e}")
570
599
  raise typer.Exit(code=1)
@@ -643,8 +672,14 @@ def service_add(
643
672
  try:
644
673
  schema_path = schema or os.path.join(directory, config_manager.SCHEMA_FILE_NAME)
645
674
  if not deployment_manifest:
646
- deployment_manifest = service_discovery.find_compose_file(directory, ".")
647
- if deployment_manifest:
675
+ found_manifest = service_discovery.find_compose_file(directory, ".")
676
+ # Auto-discovery only wires it up when the manifest actually
677
+ # names this service -- an explicit --deployment-manifest below
678
+ # always overrides this, since that's the user saying so directly.
679
+ if found_manifest and service_discovery.compose_declares_service(
680
+ found_manifest, manifest_container or name
681
+ ):
682
+ deployment_manifest = found_manifest
648
683
  console.print(
649
684
  f"[dim]Found deployment manifest {deployment_manifest} -- registering it too.[/dim]"
650
685
  )
@@ -7,6 +7,7 @@ from rich.console import Console
7
7
 
8
8
  from envshield.core.exceptions import (
9
9
  ConfigNotFoundError,
10
+ ConfigParseError,
10
11
  SchemaNotFoundError,
11
12
  SchemaParseError,
12
13
  UnsafePathError,
@@ -57,9 +58,10 @@ def load_config(path: Optional[str] = None) -> Dict[str, Any]:
57
58
  with open(config_path, "r") as f:
58
59
  config_data = yaml.safe_load(f)
59
60
  return config_data if config_data else {}
60
- except (yaml.YAMLError, IOError) as e:
61
- console.print(f"[bold red]Error:[/bold red] Failed to parse {config_path}: {e}")
62
- raise
61
+ except yaml.YAMLError as e:
62
+ raise ConfigParseError(config_path, str(e))
63
+ except IOError as e:
64
+ raise ConfigParseError(config_path, str(e))
63
65
 
64
66
 
65
67
  def load_schema(service_name: Optional[str] = None) -> Dict[str, Any]:
@@ -51,6 +51,14 @@ class SchemaParseError(EnvShieldException):
51
51
  super().__init__(self.message)
52
52
 
53
53
 
54
+ class ConfigParseError(EnvShieldException):
55
+ """Raised when the envshield.yml file cannot be parsed."""
56
+
57
+ def __init__(self, config_path: str, details: str):
58
+ self.message = f"Config parse error in {config_path}: {details}"
59
+ super().__init__(self.message)
60
+
61
+
54
62
  class UnsafePathError(EnvShieldException):
55
63
  """
56
64
  Raised when a path taken from 'envshield.yml' (a service's schema,
@@ -444,7 +444,7 @@ def run_scan(
444
444
  skipped_large_files.append(file_path)
445
445
  continue
446
446
 
447
- # C6: Diff-aware scanning for excluded files
447
+ # Diff-aware scanning for excluded files
448
448
  new_lines_only = None
449
449
  if file_path in excluded_files:
450
450
  new_lines = _get_diff_lines(file_path)
@@ -5,6 +5,8 @@
5
5
  import os
6
6
  from typing import Dict, List, Optional
7
7
 
8
+ import yaml
9
+
8
10
  from . import inspector
9
11
  from .scanner import DEFAULT_EXCLUDED_DIRS
10
12
  from ..parsers.factory import get_parser
@@ -66,6 +68,30 @@ def find_compose_file(service_dir: str, project_root: str = ".") -> Optional[str
66
68
  return None
67
69
 
68
70
 
71
+ def compose_declares_service(compose_path: str, name: str) -> bool:
72
+ """
73
+ Whether `name` appears as a top-level service key in the compose file at
74
+ `compose_path`.
75
+
76
+ A shared root compose file is found (by find_compose_file) for every
77
+ directory being registered, regardless of whether that directory is
78
+ actually one of its containers. Auto-attaching it unconditionally would
79
+ silently validate an unrelated service against the wrong container's
80
+ variables whenever the compose file happens to declare exactly one
81
+ service -- that single container gets picked with no name check at all
82
+ (see the parsers' `prefer` logic). Requiring a name match before
83
+ auto-attaching turns that into "nothing attached, register it by hand
84
+ with --deployment-manifest" instead of a silent wrong answer.
85
+ """
86
+ try:
87
+ with open(compose_path, "r") as f:
88
+ doc = yaml.safe_load(f) or {}
89
+ except (OSError, yaml.YAMLError):
90
+ return False
91
+ services = doc.get("services") if isinstance(doc, dict) else None
92
+ return isinstance(services, dict) and name in services
93
+
94
+
69
95
  def _looks_like_python_config_module(path: str) -> bool:
70
96
  parser = get_parser(path)
71
97
  if not parser:
@@ -123,6 +149,34 @@ def _find_dotenv_template(service_dir: str) -> Optional[str]:
123
149
  return None
124
150
 
125
151
 
152
+ def find_config_source(service_dir: str) -> Optional[str]:
153
+ """
154
+ Finds the best real config source at `service_dir` to seed a schema
155
+ from -- a real dotenv file, a checked-in dotenv template, or a
156
+ recognizable Python config module -- and returns its actual path.
157
+
158
+ Unlike detect_env_style, this always returns the real path when one is
159
+ found, even when it's the conventional '.env'/'.env.example' name --
160
+ detect_env_style omits that case because its caller only needs to know
161
+ when a path override is required, while this one is for callers (like
162
+ 'init') that need a real file to read.
163
+ """
164
+ real_file = _find_real_dotenv_file(service_dir)
165
+ if real_file:
166
+ return real_file
167
+
168
+ template_file = _find_dotenv_template(service_dir)
169
+ if template_file:
170
+ return template_file
171
+
172
+ for rel_path in PYTHON_CONFIG_CANDIDATES:
173
+ candidate = os.path.join(service_dir, rel_path)
174
+ if os.path.isfile(candidate) and _looks_like_python_config_module(candidate):
175
+ return candidate
176
+
177
+ return None
178
+
179
+
126
180
  def detect_env_style(service_dir: str) -> Dict[str, Optional[str]]:
127
181
  """
128
182
  Looks inside `service_dir` for how it manages environment variables.
@@ -217,11 +271,12 @@ def discover_candidates(
217
271
  example_file, deployment_manifest}. `deployment_manifest`, when found,
218
272
  is registered automatically -- no '--deployment-manifest' flag needed
219
273
  for the common case of a docker-compose file at the project root or
220
- inside the service's own directory (see find_compose_file). If its
221
- compose file lists more than one service, `check`/`doctor` still work
222
- without any extra setup as long as this service's name matches one of
223
- the compose service names (see the parsers' `prefer` hint) -- otherwise
224
- they'll report exactly which flag/edit resolves the ambiguity.
274
+ inside the service's own directory (see find_compose_file) that
275
+ declares a service matching this directory's name (see
276
+ compose_declares_service). A compose file that doesn't name this
277
+ service isn't attached at all -- register it by hand with
278
+ '--deployment-manifest'/'--container' instead of risking a silent
279
+ validation against the wrong container.
225
280
  """
226
281
  known_dirs_norm = {os.path.normpath(d) for d in (known_dirs or [])}
227
282
  candidates = []
@@ -245,6 +300,10 @@ def discover_candidates(
245
300
  name = f"{parent}-{base_name}" if parent else f"{base_name}-{seen_names[base_name]}"
246
301
  seen_names[base_name] = seen_names.get(base_name, 0) + 1
247
302
 
303
+ compose_file = find_compose_file(normalized, root)
304
+ if compose_file and not compose_declares_service(compose_file, base_name):
305
+ compose_file = None
306
+
248
307
  candidates.append(
249
308
  {
250
309
  "name": name,
@@ -253,7 +312,7 @@ def discover_candidates(
253
312
  "format": env_style["format"],
254
313
  "local_file": env_style["local_file"],
255
314
  "example_file": env_style["example_file"],
256
- "deployment_manifest": find_compose_file(normalized, root),
315
+ "deployment_manifest": compose_file,
257
316
  }
258
317
  )
259
318
 
@@ -234,3 +234,55 @@ def test_discover_candidates_includes_deployment_manifest_when_found(tmp_path):
234
234
 
235
235
  api = next(c for c in candidates if c["name"] == "api")
236
236
  assert api["deployment_manifest"] == os.path.normpath(str(tmp_path / "docker-compose.yml"))
237
+
238
+
239
+ def test_discover_candidates_does_not_attach_manifest_that_does_not_name_the_service(tmp_path):
240
+ """
241
+ Regression: find_compose_file only checks that *a* compose file exists
242
+ nearby, not that this service is actually declared in it. A shared root
243
+ compose file with exactly one container would otherwise get silently
244
+ attached to every unrelated directory discover finds -- and since a
245
+ single-container compose file is used regardless of --container/prefer,
246
+ that means validating an unrelated service against the wrong container's
247
+ variables with no error at all.
248
+ """
249
+ (tmp_path / "docs").mkdir()
250
+ (tmp_path / "docs" / ".env").write_text("KEY=1\n")
251
+ (tmp_path / "docker-compose.yml").write_text("services:\n api:\n image: x\n")
252
+
253
+ candidates = service_discovery.discover_candidates(str(tmp_path))
254
+
255
+ docs = next(c for c in candidates if c["name"] == "docs")
256
+ assert docs["deployment_manifest"] is None
257
+
258
+
259
+ def test_compose_declares_service(tmp_path):
260
+ compose = tmp_path / "docker-compose.yml"
261
+ compose.write_text("services:\n api:\n image: x\n worker:\n image: y\n")
262
+
263
+ assert service_discovery.compose_declares_service(str(compose), "api") is True
264
+ assert service_discovery.compose_declares_service(str(compose), "docs") is False
265
+
266
+
267
+ def test_find_config_source_returns_conventional_dotenv_path(tmp_path):
268
+ """
269
+ Unlike detect_env_style (which omits the path for the conventional
270
+ '.env' name since its caller only needs an override signal),
271
+ find_config_source always returns the real path -- callers like 'init'
272
+ need an actual file to read from.
273
+ """
274
+ (tmp_path / ".env").write_text("KEY=1\n")
275
+
276
+ assert service_discovery.find_config_source(str(tmp_path)) == str(tmp_path / ".env")
277
+
278
+
279
+ def test_find_config_source_finds_python_config_module(tmp_path):
280
+ (tmp_path / "config").mkdir()
281
+ settings = tmp_path / "config" / "settings.py"
282
+ settings.write_text("SECRET_KEY = 'x'\nDEBUG = True\nAPI_PORT = 5000\n")
283
+
284
+ assert service_discovery.find_config_source(str(tmp_path)) == str(settings)
285
+
286
+
287
+ def test_find_config_source_returns_none_when_nothing_found(tmp_path):
288
+ assert service_discovery.find_config_source(str(tmp_path)) is None
@@ -362,3 +362,86 @@ def test_check_without_service_runs_for_all_services(mocker, tmp_path):
362
362
  assert "── athena ──" in result.stdout
363
363
  assert "── hermes ──" in result.stdout
364
364
  assert "perfectly in sync" in result.stdout
365
+
366
+
367
+ def test_generate_scan_and_import_report_available_services_for_an_unknown_one(tmp_path):
368
+ """
369
+ Regression: 'check'/'doctor'/'setup' listed available services on an
370
+ unknown --service, but 'generate'/'scan'/'import' each had their own
371
+ inline check with no listing. All should behave the same now.
372
+ """
373
+ with runner.isolated_filesystem(temp_dir=tmp_path):
374
+ _write_two_service_project()
375
+
376
+ for args in (
377
+ ["generate", "--service", "bogus"],
378
+ ["scan", ".", "--service", "bogus"],
379
+ ["import", "athena/env.schema.toml", "--service", "bogus"],
380
+ ):
381
+ result = runner.invoke(app, args)
382
+ assert result.exit_code == 1, f"{args}: {result.stdout}"
383
+ assert "not found" in result.stdout
384
+ assert "athena, hermes" in result.stdout, f"{args}: {result.stdout}"
385
+
386
+
387
+ def test_import_command_syncs_the_env_example_template(tmp_path):
388
+ """Regression: import used to leave .env.example stale after changing the schema it feeds."""
389
+ with runner.isolated_filesystem(temp_dir=tmp_path):
390
+ with open("settings.py", "w") as f:
391
+ f.write("SECRET_KEY = 'x'\nAPI_PORT = 5000\n")
392
+
393
+ result = runner.invoke(app, ["import", "settings.py"])
394
+
395
+ assert result.exit_code == 0
396
+ assert os.path.exists(".env.example")
397
+ with open(".env.example", "r") as f:
398
+ content = f.read()
399
+ assert "SECRET_KEY" in content
400
+ assert "API_PORT" in content
401
+
402
+
403
+ def test_init_builds_schema_from_a_real_config_source_instead_of_the_template(tmp_path, mocker):
404
+ """
405
+ Regression: init used to always write the generic framework template even
406
+ when the project already had real config (e.g. config/settings.py) sitting
407
+ right there, forcing a separate 'envshield import ... --force' just to get
408
+ an accurate schema.
409
+ """
410
+ with runner.isolated_filesystem(temp_dir=tmp_path):
411
+ os.system("git init -q")
412
+ mocker.patch("questionary.confirm").return_value.ask.return_value = False
413
+ with open("requirements.txt", "w") as f:
414
+ f.write("Flask\n")
415
+ os.makedirs("config")
416
+ with open("config/settings.py", "w") as f:
417
+ f.write(
418
+ "SECRET_KEY = 'x'\nDATABASE_URL = 'postgres://x'\n"
419
+ "DEBUG = True\nAPI_PORT = 5000\nLOG_LEVEL = 'info'\n"
420
+ )
421
+
422
+ result = runner.invoke(app, ["init"])
423
+
424
+ assert result.exit_code == 0, result.stdout
425
+ with open(SCHEMA_FILE_NAME, "r") as f:
426
+ content = f.read()
427
+ # The fixed python-flask template only ever has these three --
428
+ # DEBUG/API_PORT/LOG_LEVEL prove the real file was used instead.
429
+ assert "DEBUG" in content
430
+ assert "API_PORT" in content
431
+ assert "LOG_LEVEL" in content
432
+
433
+
434
+ def test_init_falls_back_to_the_framework_template_when_no_real_config_exists(tmp_path, mocker):
435
+ with runner.isolated_filesystem(temp_dir=tmp_path):
436
+ os.system("git init -q")
437
+ mocker.patch("questionary.confirm").return_value.ask.return_value = False
438
+ with open("requirements.txt", "w") as f:
439
+ f.write("Flask\n")
440
+
441
+ result = runner.invoke(app, ["init"])
442
+
443
+ assert result.exit_code == 0, result.stdout
444
+ with open(SCHEMA_FILE_NAME, "r") as f:
445
+ content = f.read()
446
+ assert "SECRET_KEY" in content
447
+ assert "FLASK_ENV" in content
@@ -4,7 +4,12 @@ import os
4
4
  import pytest
5
5
 
6
6
  from envshield.config import manager as config_manager
7
- from envshield.core.exceptions import SchemaNotFoundError, SchemaParseError, UnsafePathError
7
+ from envshield.core.exceptions import (
8
+ ConfigParseError,
9
+ SchemaNotFoundError,
10
+ SchemaParseError,
11
+ UnsafePathError,
12
+ )
8
13
 
9
14
 
10
15
  def test_update_gitignore_creates_file_with_env_pattern(tmp_path, monkeypatch):
@@ -268,6 +273,21 @@ def test_load_schema_supports_multiple_extends_with_later_entries_winning(tmp_pa
268
273
  assert schema["SHARED"]["description"] == "from b"
269
274
 
270
275
 
276
+ def test_load_config_raises_clean_error_on_malformed_yaml(tmp_path, monkeypatch):
277
+ """
278
+ Regression: a malformed envshield.yml used to print a message and then
279
+ re-raise the raw yaml.YAMLError, which no cli.py handler catches (they
280
+ only catch EnvShieldException) -- producing an unhandled traceback
281
+ instead of the clean error every other parse failure gets.
282
+ """
283
+ monkeypatch.chdir(tmp_path)
284
+ with open("envshield.yml", "w") as f:
285
+ f.write("services: [unclosed\n")
286
+
287
+ with pytest.raises(ConfigParseError, match="envshield.yml"):
288
+ config_manager.load_config()
289
+
290
+
271
291
  def test_load_schema_detects_circular_extends(tmp_path, monkeypatch):
272
292
  monkeypatch.chdir(tmp_path)
273
293
  with open("a.schema.toml", "w") as f:
@@ -1,4 +1,4 @@
1
- """Tests for C6: Diff-aware secret scanning in excluded files."""
1
+ """Tests for diff-aware secret scanning in excluded files."""
2
2
 
3
3
  from unittest.mock import patch
4
4
 
@@ -187,6 +187,25 @@ def test_service_add_auto_detects_compose_file_in_service_directory(tmp_path):
187
187
  )
188
188
 
189
189
 
190
+ def test_service_add_does_not_auto_attach_a_manifest_that_does_not_name_it(tmp_path):
191
+ """
192
+ Regression: a shared root compose file used to get auto-attached to
193
+ any service directory regardless of whether it's actually declared in
194
+ it -- silently validating against the wrong container. An explicit
195
+ --deployment-manifest still always works (see the test right below).
196
+ """
197
+ with runner.isolated_filesystem(temp_dir=tmp_path):
198
+ os.makedirs("docs")
199
+ with open("docker-compose.yml", "w") as f:
200
+ f.write("services:\n api:\n image: x\n")
201
+
202
+ result = runner.invoke(app, ["service", "add", "docs", "docs"])
203
+
204
+ assert result.exit_code == 0
205
+ assert config_manager.get_services()["docs"].get("deployment_manifest") is None
206
+ assert "docker-compose.yml" not in result.stdout
207
+
208
+
190
209
  def test_service_add_explicit_deployment_manifest_and_container(tmp_path):
191
210
  with runner.isolated_filesystem(temp_dir=tmp_path):
192
211
  os.makedirs("api")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: EnvShield: A CLI for secure local environment management and secret prevention.
5
5
  Author-email: Rabbil Yasar Sajal <rabbilyasar@gmail.com>
6
6
  License: MIT License
@@ -84,12 +84,14 @@ It works the same way whether you have one repo with one `.env` file, or a monor
84
84
  - [Installation](#installation)
85
85
  - [Quick start](#quick-start)
86
86
  - [A single service](#a-single-service)
87
- - [A monorepo with multiple services](#a-monorepo-with-multiple-services)
87
+ - [A monorepo with multiple services (monorepo)](#a-monorepo-with-multiple-services)
88
88
  - [Core concept: the schema is the contract](#core-concept-the-schema-is-the-contract)
89
89
  - [Every field a variable can have](#every-field-a-variable-can-have)
90
90
  - [Conditional requirements (`requiredIf`)](#conditional-requirements-requiredif)
91
- - [Sharing variables across services (`extends`)](#sharing-variables-across-services-extends)
91
+ - [Sharing variables across services (monorepo, `extends`)](#sharing-variables-across-services-extends)
92
92
  - [Command reference](#command-reference)
93
+ - [Core commands](#core-commands)
94
+ - [Monorepo: managing multiple services (monorepo)](#monorepo-managing-multiple-services)
93
95
  - [Typed config code generation](#typed-config-code-generation)
94
96
  - [Validating deployment manifests](#validating-deployment-manifests)
95
97
  - [Secret scanning and git hooks](#secret-scanning-and-git-hooks)
@@ -138,22 +140,28 @@ envshield --version
138
140
 
139
141
  ### A single service
140
142
 
141
- The common case: one repo, one `.env` file.
143
+ The common case: one repo, one `.env` file. This is the whole workflow — nothing else in this README is required to get full value out of EnvShield.
142
144
 
143
145
  ```bash
144
146
  cd my-project
145
- envshield init # Detects your framework, creates env.schema.toml + envshield.yml
146
- envshield import .env # Or: seed the schema from an existing .env file
147
+ envshield init # Detects your framework and builds env.schema.toml from your real config
147
148
  envshield setup # Interactive wizard: fills in .env from the schema
148
149
  envshield generate --lang python # Generates a typed config.py (or config.ts for TypeScript)
149
150
  ```
150
151
 
152
+ `init` looks for a real config source first — an existing `.env`, `.env.example`, or a recognizable Python config module (`config/settings.py` and similar) — and builds the schema from its actual variables, classifying each as secret or not, with a suggested default and, where the value's shape is unambiguous, an inferred type. Only a genuinely fresh project with nothing to read yet falls back to a generic framework template:
153
+
154
+ ```
155
+ Found config/settings.py -- building your schema from its real variables.
156
+ ✓ Created/updated schema: env.schema.toml
157
+ ```
158
+
151
159
  `init` also offers to install a git pre-commit hook (secret scanning) and a post-merge hook (drift check after every `git pull`) — say yes unless you already have your own hook-management tooling (Husky, `pre-commit`, etc.), in which case EnvShield will detect it and won't clobber it (see [Secret scanning and git hooks](#secret-scanning-and-git-hooks)).
152
160
 
153
- If you already have a real `.env` with real values, `import` is usually the better starting point than `init`'s blank framework defaults — it reads your actual file and classifies each variable (secret or not, with a suggested default and, where the value's shape is unambiguous, a type) in seconds:
161
+ `envshield import <file>` does the same real-variable analysis `init` runs automatically, as its own command — reach for it later, when you want to re-import after adding new variables to your code, point at a file `init` wouldn't have found on its own, or add `--interactive` to confirm each classification by hand instead of accepting the automatic guess:
154
162
 
155
163
  ```bash
156
- envshield import .env
164
+ envshield import .env --interactive
157
165
  ```
158
166
 
159
167
  ```
@@ -166,8 +174,14 @@ Analyzing variables...
166
174
  - Inferred a type (int/port/bool/url/email) for 3 variable(s).
167
175
  ```
168
176
 
177
+ ---
178
+
179
+ **Everything above is the complete single-service story.** Everything below this point — multiple services, schema composition, deployment manifests with more than one container — is opt-in, and only relevant once your repo actually has more than one service. Skip straight to [Core concept: the schema is the contract](#core-concept-the-schema-is-the-contract) if that's not you yet.
180
+
169
181
  ### A monorepo with multiple services
170
182
 
183
+ *(Optional — skip this if you only have one service.)*
184
+
171
185
  If your repo has more than one service (an API, a web frontend, a worker), run `service discover` at the repo root instead of `init`:
172
186
 
173
187
  ```bash
@@ -300,6 +314,8 @@ With `FEATURE_X_ENABLED=false`, `FEATURE_X_API_KEY` is optional — `setup` won'
300
314
 
301
315
  ### Sharing variables across services (`extends`)
302
316
 
317
+ *(Monorepo-only — skip this if you have one service.)*
318
+
303
319
  A monorepo with ten services usually has five or six variables every single one of them needs — `LOG_LEVEL`, `SENTRY_DSN`, `DATADOG_API_KEY` — and copy-pasting the same `[LOG_LEVEL]` block into ten schema files is exactly the kind of drift EnvShield exists to prevent.
304
320
 
305
321
  Factor them into a shared base schema, and have each service extend it:
@@ -336,22 +352,21 @@ Loading `services/api/env.schema.toml` now transparently gives you `LOG_LEVEL`,
336
352
 
337
353
  ## Command reference
338
354
 
339
- Every command that takes `--service` also works without it: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.
355
+ ### Core commands
356
+
357
+ Everything a single-service project ever needs. `--service` shows up on most of these for the multi-service case (see below), but it's entirely optional until you actually have more than one service.
340
358
 
341
359
  | Command | What it does |
342
360
  |---|---|
343
- | `envshield init [--force/-f]` | Detects your framework, scaffolds `env.schema.toml` + `envshield.yml`, updates `.gitignore`, and offers to install git hooks. Auto-registers a root-level `docker-compose.yml` as the project's deployment manifest if it finds one. `--force` re-runs on a project that already has a config (with a confirmation before overwriting). |
344
- | `envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME]` | Converts an existing `.env` file or Python config module into `env.schema.toml`, classifying each variable as secret or not, suggesting a default, and inferring a type where the value's shape is unambiguous. `--interactive` confirms each classification with you instead of applying it silently. `--output` changes where the schema is written (defaults to `env.schema.toml`, or the target service's schema path with `--service`). |
361
+ | `envshield init [--force/-f]` | Detects your framework and builds `env.schema.toml` from a real config source if it finds one, otherwise a framework-aware template. Also scaffolds `envshield.yml`, updates `.gitignore`, and offers to install git hooks. Auto-registers a root-level `docker-compose.yml` as the project's deployment manifest if it finds one. `--force` re-runs on a project that already has a config (with a confirmation before overwriting). |
362
+ | `envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME]` | Runs the same real-variable analysis `init` does automatically, as its own command — for re-importing after your code gains new variables, pointing at a file `init` wouldn't have found, or adding `--interactive` to confirm each secret/type classification by hand instead of accepting the automatic guess. `--output` changes where the schema is written (defaults to `env.schema.toml`, or the target service's schema path with `--service`). |
345
363
  | `envshield check [file] [--service NAME] [--container NAME]` | Validates a local file (or, if omitted, the project's/service's default local file *and* its registered deployment manifest, if any) against the schema. `file` can be a plain `.env`, a Python config module, a docker-compose file, or a Kubernetes manifest. `--container` picks which service/container to check in a manifest that declares more than one (tried against `--service`'s name automatically first). Exits non-zero on any drift — safe to use as a CI gate. |
346
364
  | `envshield doctor [--fix] [--service NAME]` | Runs every health check at once (see below) and reports a summary. `--fix` interactively offers to fix whatever it can — re-running `init`, regenerating the template, installing the git hook, or running `setup` to fill in missing/invalid local values. Exits non-zero if anything's still broken afterward. |
347
365
  | `envshield setup [output_file] [--service NAME]` | Interactive onboarding wizard: walks through every variable that's missing, blank, or has an existing value the schema no longer allows, prompting with the variable's description, masking secret input, and offering a picker for `enum` fields. Leaves everything already correct untouched. |
348
- | `envshield schema sync [--service NAME]` | Regenerates `.env.example` from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). |
366
+ | `envshield schema sync [--service NAME]` | Regenerates `.env.example` from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). `import` already calls this automatically for you when it changes a project's/service's real schema, so you'll rarely need to run it by hand except after a manual schema edit. |
349
367
  | `envshield generate [output_file] [--lang/-l python\|typescript] [--force/-f] [--service NAME]` | Compiles the schema into a typed, validated config module. `--lang` is auto-detected from your project (Next.js/Vite/Node.js → TypeScript, everything else → Python) if omitted. Defaults to writing `config.py`/`config.ts`; `--force` overwrites an existing output file. See [Typed config code generation](#typed-config-code-generation). |
350
368
  | `envshield scan [paths...] [--staged] [--config/-c PATH] [--exclude/-e PATTERN]` [--service NAME] | Scans code for hardcoded secrets and for env vars used in code (`os.getenv`, `os.environ.get`, `process.env.X`) but never declared in the schema. `--staged` scans only what's staged for the next commit (what the pre-commit hook runs); `--exclude` (repeatable) adds glob patterns to skip, on top of whatever `secret_scanning.exclude_files` is set in `envshield.yml`. See [Secret scanning and git hooks](#secret-scanning-and-git-hooks). |
351
369
  | `envshield install-hook` | Installs both git hooks by hand, without going through `init`/`setup`/`service discover`'s interactive prompt. |
352
- | `envshield service list` | Lists every service currently configured in `envshield.yml`, with its schema and local file paths. |
353
- | `envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME]` | Registers one service by hand. `--local-file` is required when the service's real config isn't a dotenv file (e.g. a Python module) — see below. `--import` seeds the new service's schema from an existing config file in one step. `--deployment-manifest` is auto-detected (a compose file in the given directory, or the project root) if not given explicitly. |
354
- | `envshield service discover [root] [--yes/-y]` | Scans for service-like directories not already registered, and offers to add them — see [Quick start](#a-monorepo-with-multiple-services). `--yes` skips the interactive confirmation, for CI/scripting. |
355
370
  | `envshield --version` / `-v` | Prints the installed version and exits. |
356
371
 
357
372
  **Not using a `.env` file at all?** Some projects (a Flask app whose local config is a checked-in Python module, for example) don't use dotenv at all. Point `local_file` at it instead, and EnvShield reads and writes it as source code, not as a dotenv file — appending or patching only the specific assignments it owns, never touching anything else in the file:
@@ -363,6 +378,16 @@ services:
363
378
  local_file: athena/config/env_config.local.py
364
379
  ```
365
380
 
381
+ ### Monorepo: managing multiple services
382
+
383
+ *(Only relevant once your repo has more than one service — see [A monorepo with multiple services](#a-monorepo-with-multiple-services).)* Every core command above already accepts `--service`: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.
384
+
385
+ | Command | What it does |
386
+ |---|---|
387
+ | `envshield service list` | Lists every service currently configured in `envshield.yml`, with its schema and local file paths. |
388
+ | `envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME]` | Registers one service by hand. `--local-file` is required when the service's real config isn't a dotenv file (e.g. a Python module) — see above. `--import` seeds the new service's schema from an existing config file in one step. `--deployment-manifest` is auto-detected (a compose file in the given directory or the project root that actually declares this service) if not given explicitly. |
389
+ | `envshield service discover [root] [--yes/-y]` | Scans for service-like directories not already registered, and offers to add them — see [Quick start](#a-monorepo-with-multiple-services). `--yes` skips the interactive confirmation, for CI/scripting. |
390
+
366
391
  ---
367
392
 
368
393
  ## Typed config code generation
@@ -37,9 +37,9 @@ envshield/parsers/_python.py
37
37
  envshield/parsers/factory.py
38
38
  envshield/tests/__init__.py
39
39
  envshield/tests/conftest.py
40
- envshield/tests/test_c6_diff_aware_scanning.py
41
40
  envshield/tests/test_cli.py
42
41
  envshield/tests/test_config_manager.py
42
+ envshield/tests/test_diff_aware_scanning.py
43
43
  envshield/tests/test_git_utils.py
44
44
  envshield/tests/test_service_cli.py
45
45
  envshield/tests/core/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "envshield"
7
- version = "4.2.0" # must be a string for PEP 621 compliance
7
+ version = "4.3.0" # must be a string for PEP 621 compliance
8
8
  description = "EnvShield: A CLI for secure local environment management and secret prevention."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1 +0,0 @@
1
- __version__ = "4.2.0"
File without changes
File without changes
File without changes