envctl 2.3.2__tar.gz → 2.3.4__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 (157) hide show
  1. {envctl-2.3.2/src/envctl.egg-info → envctl-2.3.4}/PKG-INFO +81 -1
  2. {envctl-2.3.2 → envctl-2.3.4}/README.md +80 -0
  3. {envctl-2.3.2 → envctl-2.3.4}/pyproject.toml +2 -2
  4. envctl-2.3.4/src/envctl/adapters/process_environment.py +17 -0
  5. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/app.py +15 -2
  6. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/check/command.py +5 -2
  7. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/explain/command.py +4 -0
  8. envctl-2.3.4/src/envctl/cli/commands/export/command.py +38 -0
  9. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/inspect/command.py +5 -2
  10. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/remove/command.py +2 -0
  11. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/run/command.py +6 -3
  12. envctl-2.3.4/src/envctl/cli/commands/sync/command.py +35 -0
  13. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/__init__.py +2 -0
  14. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/action_presenter.py +21 -0
  15. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/resolution_presenter.py +10 -1
  16. envctl-2.3.4/src/envctl/cli/presenters/run_presenter.py +11 -0
  17. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/prompts/confirmation_prompts.py +3 -0
  18. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/runtime.py +8 -1
  19. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/serializers.py +12 -0
  20. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/config/loader.py +3 -24
  21. envctl-2.3.4/src/envctl/config/profile_resolution.py +54 -0
  22. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/contract.py +12 -2
  23. envctl-2.3.4/src/envctl/domain/expansion.py +101 -0
  24. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/operations.py +12 -0
  25. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/resolution.py +6 -0
  26. envctl-2.3.4/src/envctl/repository/profile_repository.py +140 -0
  27. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/add_service.py +13 -15
  28. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/check_service.py +4 -1
  29. envctl-2.3.4/src/envctl/services/doctor_service.py +161 -0
  30. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/export_service.py +28 -6
  31. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/fill_service.py +13 -13
  32. envctl-2.3.4/src/envctl/services/group_selection_service.py +65 -0
  33. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/inspect_service.py +4 -1
  34. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/profile_service.py +14 -40
  35. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/rebind_service.py +6 -8
  36. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/remove_service.py +35 -32
  37. envctl-2.3.4/src/envctl/services/resolution_service.py +506 -0
  38. envctl-2.3.4/src/envctl/services/run_service.py +127 -0
  39. envctl-2.3.4/src/envctl/services/set_service.py +35 -0
  40. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/status_service.py +6 -2
  41. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/sync_service.py +24 -7
  42. envctl-2.3.4/src/envctl/services/unset_service.py +35 -0
  43. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/vault_service.py +51 -31
  44. envctl-2.3.4/src/envctl/utils/projection_rendering.py +77 -0
  45. {envctl-2.3.2 → envctl-2.3.4/src/envctl.egg-info}/PKG-INFO +81 -1
  46. {envctl-2.3.2 → envctl-2.3.4}/src/envctl.egg-info/SOURCES.txt +7 -0
  47. envctl-2.3.2/src/envctl/cli/commands/export/command.py +0 -16
  48. envctl-2.3.2/src/envctl/cli/commands/sync/command.py +0 -16
  49. envctl-2.3.2/src/envctl/services/doctor_service.py +0 -177
  50. envctl-2.3.2/src/envctl/services/resolution_service.py +0 -189
  51. envctl-2.3.2/src/envctl/services/run_service.py +0 -56
  52. envctl-2.3.2/src/envctl/services/set_service.py +0 -35
  53. envctl-2.3.2/src/envctl/services/unset_service.py +0 -35
  54. {envctl-2.3.2 → envctl-2.3.4}/LICENSE +0 -0
  55. {envctl-2.3.2 → envctl-2.3.4}/setup.cfg +0 -0
  56. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/__init__.py +0 -0
  57. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/__main__.py +0 -0
  58. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/adapters/__init__.py +0 -0
  59. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/adapters/dotenv.py +0 -0
  60. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/adapters/editor.py +0 -0
  61. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/adapters/git.py +0 -0
  62. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/adapters/input.py +0 -0
  63. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/__init__.py +0 -0
  64. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/callbacks.py +0 -0
  65. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/__init__.py +0 -0
  66. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/add/__init__.py +0 -0
  67. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/add/command.py +0 -0
  68. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/check/__init__.py +0 -0
  69. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/config/__init__.py +0 -0
  70. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/config/app.py +0 -0
  71. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/doctor/__init__.py +0 -0
  72. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/doctor/command.py +0 -0
  73. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/explain/__init__.py +0 -0
  74. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/export/__init__.py +0 -0
  75. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/fill/__init__.py +0 -0
  76. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/fill/command.py +0 -0
  77. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/init/__init__.py +0 -0
  78. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/init/command.py +0 -0
  79. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/inspect/__init__.py +0 -0
  80. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/__init__.py +0 -0
  81. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/app.py +0 -0
  82. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/commands/__init__.py +0 -0
  83. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/commands/copy.py +0 -0
  84. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/commands/create.py +0 -0
  85. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/commands/list.py +0 -0
  86. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/commands/path.py +0 -0
  87. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/profile/commands/remove.py +0 -0
  88. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/__init__.py +0 -0
  89. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/app.py +0 -0
  90. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/commands/__init__.py +0 -0
  91. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/commands/bind.py +0 -0
  92. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/commands/rebind.py +0 -0
  93. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/commands/repair.py +0 -0
  94. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/project/commands/unbind.py +0 -0
  95. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/remove/__init__.py +0 -0
  96. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/run/__init__.py +0 -0
  97. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/set/__init__.py +0 -0
  98. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/set/command.py +0 -0
  99. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/status/__init__.py +0 -0
  100. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/status/command.py +0 -0
  101. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/sync/__init__.py +0 -0
  102. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/unset/__init__.py +0 -0
  103. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/unset/command.py +0 -0
  104. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/__init__.py +0 -0
  105. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/app.py +0 -0
  106. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/commands/__init__.py +0 -0
  107. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/commands/check.py +0 -0
  108. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/commands/edit.py +0 -0
  109. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/commands/path.py +0 -0
  110. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/commands/prune.py +0 -0
  111. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/commands/vault/commands/show.py +0 -0
  112. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/decorators.py +0 -0
  113. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/common.py +0 -0
  114. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/doctor_presenter.py +0 -0
  115. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/profile_presenter.py +0 -0
  116. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/project_presenter.py +0 -0
  117. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/status_presenter.py +0 -0
  118. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/presenters/vault_presenter.py +0 -0
  119. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/cli/prompts/__init__.py +0 -0
  120. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/config/__init__.py +0 -0
  121. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/config/defaults.py +0 -0
  122. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/config/writer.py +0 -0
  123. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/constants.py +0 -0
  124. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/__init__.py +0 -0
  125. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/app_config.py +0 -0
  126. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/contract_inference.py +0 -0
  127. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/doctor.py +0 -0
  128. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/project.py +0 -0
  129. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/runtime.py +0 -0
  130. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/domain/status.py +0 -0
  131. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/errors.py +0 -0
  132. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/repository/__init__.py +0 -0
  133. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/repository/contract_repository.py +0 -0
  134. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/repository/project_context.py +0 -0
  135. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/repository/state_repository.py +0 -0
  136. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/__init__.py +0 -0
  137. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/bind_service.py +0 -0
  138. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/config_service.py +0 -0
  139. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/context_service.py +0 -0
  140. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/explain_service.py +0 -0
  141. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/init_service.py +0 -0
  142. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/repair_service.py +0 -0
  143. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/services/unbind_service.py +0 -0
  144. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/__init__.py +0 -0
  145. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/atomic.py +0 -0
  146. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/filesystem.py +0 -0
  147. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/masking.py +0 -0
  148. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/output.py +0 -0
  149. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/project_ids.py +0 -0
  150. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/project_names.py +0 -0
  151. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/project_paths.py +0 -0
  152. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/shells.py +0 -0
  153. {envctl-2.3.2 → envctl-2.3.4}/src/envctl/utils/tilde.py +0 -0
  154. {envctl-2.3.2 → envctl-2.3.4}/src/envctl.egg-info/dependency_links.txt +0 -0
  155. {envctl-2.3.2 → envctl-2.3.4}/src/envctl.egg-info/entry_points.txt +0 -0
  156. {envctl-2.3.2 → envctl-2.3.4}/src/envctl.egg-info/requires.txt +0 -0
  157. {envctl-2.3.2 → envctl-2.3.4}/src/envctl.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envctl
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Local environment control plane for contract-driven development workflows
5
5
  Author-email: Alessandro Barbagallo <alessbarb@gmail.com>
6
6
  License-Expression: MIT
@@ -97,6 +97,8 @@ envctl check # validate against the contract
97
97
  envctl run -- python app.py # run with env injected
98
98
  ```
99
99
 
100
+ If you use `envctl run -- docker run ...`, `envctl` injects into the Docker client process, not directly into the container. Forward container variables explicitly with `-e`, `--env`, or `--env-file`.
101
+
100
102
  ---
101
103
 
102
104
  ## Why not just `.env.local`?
@@ -134,6 +136,13 @@ Think of it like this:
134
136
 
135
137
  > the repo defines the rules, your machine provides the data, and envctl builds the final environment.
136
138
 
139
+ Resolution now includes placeholder expansion as part of the runtime model, so `check`, `inspect`,
140
+ `run`, `sync`, and `export` all see the same final value.
141
+
142
+ Contracts can also attach an optional human-facing `group` label to variables for organization,
143
+ filtering, and dotenv section rendering. `group` is not a namespace, is not hierarchical, and does
144
+ not change resolution or dependency semantics.
145
+
137
146
  ---
138
147
 
139
148
  ### Example contract
@@ -162,6 +171,12 @@ variables:
162
171
  format: json
163
172
  required: false
164
173
  sensitive: false
174
+ APP_URL:
175
+ type: string
176
+ required: true
177
+ sensitive: false
178
+ group: Application
179
+ default: http://${APP_NAME}:${PORT}
165
180
  ```
166
181
 
167
182
  This file describes what exists.
@@ -169,6 +184,71 @@ It never contains real values.
169
184
 
170
185
  ---
171
186
 
187
+ ## Variable expansion
188
+
189
+ `envctl` supports explicit placeholder expansion with `${VAR}` during resolution.
190
+
191
+ That means the expansion happens before projection, so the effective expanded value is what:
192
+
193
+ * `inspect` shows
194
+ * `check` validates
195
+ * `run` injects
196
+ * `sync` writes
197
+ * `export` prints
198
+
199
+ Example:
200
+
201
+ ```dotenv
202
+ INFRA_NEO4J_USER=neo4j
203
+ INFRA_NEO4J_PASSWORD=super-secret
204
+ INFRA_NEO4J_AUTH=${INFRA_NEO4J_USER}/${INFRA_NEO4J_PASSWORD}
205
+ ```
206
+
207
+ `INFRA_NEO4J_AUTH` resolves to the final runtime value, not the literal expression.
208
+
209
+ Rules:
210
+
211
+ * only `${VAR}` is supported in v1
212
+ * `$VAR` stays literal
213
+ * if `VAR` is a declared envctl key, envctl resolves that key first
214
+ * otherwise envctl falls back to the current process environment
215
+ * `${HOME}` works when `HOME` exists in the current process environment
216
+ * malformed placeholders or unresolved references make resolution invalid
217
+
218
+ Compatibility notes:
219
+
220
+ * before this feature, `${HOME}` stayed literal
221
+ * now `${HOME}` is expanded during resolution
222
+ * `${...}` literal escaping is not supported in v1
223
+
224
+ ## Optional groups
225
+
226
+ Each contract variable may define an optional `group` label:
227
+
228
+ ```yaml
229
+ variables:
230
+ DATABASE_URL:
231
+ type: url
232
+ required: true
233
+ sensitive: true
234
+ group: Database
235
+ ```
236
+
237
+ `group` is used only for:
238
+
239
+ * organization in the contract
240
+ * CLI targeting with `--group`
241
+ * grouped dotenv output from `sync` and `export --format dotenv`
242
+
243
+ It does not:
244
+
245
+ * create namespaces
246
+ * affect `${VAR}` expansion rules
247
+ * restrict cross-variable references
248
+ * imply hierarchy, inheritance, or prefix matching
249
+
250
+ ---
251
+
172
252
  ## Profiles
173
253
 
174
254
  Instead of juggling multiple `.env` files:
@@ -58,6 +58,8 @@ envctl check # validate against the contract
58
58
  envctl run -- python app.py # run with env injected
59
59
  ```
60
60
 
61
+ If you use `envctl run -- docker run ...`, `envctl` injects into the Docker client process, not directly into the container. Forward container variables explicitly with `-e`, `--env`, or `--env-file`.
62
+
61
63
  ---
62
64
 
63
65
  ## Why not just `.env.local`?
@@ -95,6 +97,13 @@ Think of it like this:
95
97
 
96
98
  > the repo defines the rules, your machine provides the data, and envctl builds the final environment.
97
99
 
100
+ Resolution now includes placeholder expansion as part of the runtime model, so `check`, `inspect`,
101
+ `run`, `sync`, and `export` all see the same final value.
102
+
103
+ Contracts can also attach an optional human-facing `group` label to variables for organization,
104
+ filtering, and dotenv section rendering. `group` is not a namespace, is not hierarchical, and does
105
+ not change resolution or dependency semantics.
106
+
98
107
  ---
99
108
 
100
109
  ### Example contract
@@ -123,6 +132,12 @@ variables:
123
132
  format: json
124
133
  required: false
125
134
  sensitive: false
135
+ APP_URL:
136
+ type: string
137
+ required: true
138
+ sensitive: false
139
+ group: Application
140
+ default: http://${APP_NAME}:${PORT}
126
141
  ```
127
142
 
128
143
  This file describes what exists.
@@ -130,6 +145,71 @@ It never contains real values.
130
145
 
131
146
  ---
132
147
 
148
+ ## Variable expansion
149
+
150
+ `envctl` supports explicit placeholder expansion with `${VAR}` during resolution.
151
+
152
+ That means the expansion happens before projection, so the effective expanded value is what:
153
+
154
+ * `inspect` shows
155
+ * `check` validates
156
+ * `run` injects
157
+ * `sync` writes
158
+ * `export` prints
159
+
160
+ Example:
161
+
162
+ ```dotenv
163
+ INFRA_NEO4J_USER=neo4j
164
+ INFRA_NEO4J_PASSWORD=super-secret
165
+ INFRA_NEO4J_AUTH=${INFRA_NEO4J_USER}/${INFRA_NEO4J_PASSWORD}
166
+ ```
167
+
168
+ `INFRA_NEO4J_AUTH` resolves to the final runtime value, not the literal expression.
169
+
170
+ Rules:
171
+
172
+ * only `${VAR}` is supported in v1
173
+ * `$VAR` stays literal
174
+ * if `VAR` is a declared envctl key, envctl resolves that key first
175
+ * otherwise envctl falls back to the current process environment
176
+ * `${HOME}` works when `HOME` exists in the current process environment
177
+ * malformed placeholders or unresolved references make resolution invalid
178
+
179
+ Compatibility notes:
180
+
181
+ * before this feature, `${HOME}` stayed literal
182
+ * now `${HOME}` is expanded during resolution
183
+ * `${...}` literal escaping is not supported in v1
184
+
185
+ ## Optional groups
186
+
187
+ Each contract variable may define an optional `group` label:
188
+
189
+ ```yaml
190
+ variables:
191
+ DATABASE_URL:
192
+ type: url
193
+ required: true
194
+ sensitive: true
195
+ group: Database
196
+ ```
197
+
198
+ `group` is used only for:
199
+
200
+ * organization in the contract
201
+ * CLI targeting with `--group`
202
+ * grouped dotenv output from `sync` and `export --format dotenv`
203
+
204
+ It does not:
205
+
206
+ * create namespaces
207
+ * affect `${VAR}` expansion rules
208
+ * restrict cross-variable references
209
+ * imply hierarchy, inheritance, or prefix matching
210
+
211
+ ---
212
+
133
213
  ## Profiles
134
214
 
135
215
  Instead of juggling multiple `.env` files:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "envctl"
7
- version = "2.3.2"
7
+ version = "2.3.4"
8
8
  description = "Local environment control plane for contract-driven development workflows"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,7 +71,7 @@ where = ["src"]
71
71
 
72
72
  [tool.pytest.ini_options]
73
73
  testpaths = ["tests"]
74
- addopts = "-q --strict-markers --strict-config"
74
+ addopts = "-q --strict-markers --strict-config --import-mode=importlib"
75
75
  pythonpath = ["src"]
76
76
 
77
77
  [tool.ruff]
@@ -0,0 +1,17 @@
1
+ """Access to process environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+
9
+ class ProcessEnvironmentProvider:
10
+ """Resolve variables from the current process environment."""
11
+
12
+ def __init__(self, environ: Mapping[str, str] | None = None) -> None:
13
+ self._environ = environ if environ is not None else os.environ
14
+
15
+ def get(self, key: str) -> str | None:
16
+ """Return one process environment value."""
17
+ return self._environ.get(key)
@@ -24,7 +24,8 @@ from envctl.cli.commands.sync import sync_command
24
24
  from envctl.cli.commands.unset import unset_command
25
25
  from envctl.cli.commands.vault import vault_app
26
26
  from envctl.cli.runtime import set_cli_state
27
- from envctl.config.loader import resolve_default_profile
27
+ from envctl.config.loader import load_config
28
+ from envctl.config.profile_resolution import resolve_active_profile
28
29
  from envctl.domain.runtime import OutputFormat
29
30
 
30
31
  VERSION_OPTION = typer.Option(
@@ -46,6 +47,12 @@ PROFILE_OPTION = typer.Option(
46
47
  "-p",
47
48
  help="Select the active environment profile.",
48
49
  )
50
+ GROUP_OPTION = typer.Option(
51
+ None,
52
+ "--group",
53
+ "-g",
54
+ help="Target only variables whose contract group matches LABEL exactly.",
55
+ )
49
56
 
50
57
  app = typer.Typer(help="envctl - local environment control plane")
51
58
  app.add_typer(config_app, name="config")
@@ -60,16 +67,22 @@ def main(
60
67
  version: bool = VERSION_OPTION,
61
68
  json_output: bool = JSON_OPTION,
62
69
  profile: str | None = PROFILE_OPTION,
70
+ group: str | None = GROUP_OPTION,
63
71
  ) -> None:
64
72
  """envctl - local environment control plane."""
65
73
  del version
66
74
 
67
- active_profile = profile.strip().lower() if profile is not None else resolve_default_profile()
75
+ config = load_config()
76
+ active_profile = resolve_active_profile(
77
+ profile,
78
+ config_default_profile=config.default_profile,
79
+ )
68
80
 
69
81
  set_cli_state(
70
82
  ctx,
71
83
  output_format=OutputFormat.JSON if json_output else OutputFormat.TEXT,
72
84
  profile=active_profile,
85
+ group=group.strip() or None if group is not None else None,
73
86
  )
74
87
 
75
88
 
@@ -6,7 +6,7 @@ import typer
6
6
 
7
7
  from envctl.cli.decorators import handle_errors
8
8
  from envctl.cli.presenters import render_resolution_view
9
- from envctl.cli.runtime import get_active_profile, is_json_output
9
+ from envctl.cli.runtime import get_active_profile, get_selected_group, is_json_output
10
10
  from envctl.cli.serializers import (
11
11
  emit_json,
12
12
  serialize_project_context,
@@ -24,7 +24,8 @@ def _is_check_ok(*, is_valid: bool, unknown_keys: list[str] | tuple[str, ...]) -
24
24
  @handle_errors
25
25
  def check_command() -> None:
26
26
  """Validate the current project environment against the contract."""
27
- context, active_profile, report = run_check(get_active_profile())
27
+ selected_group = get_selected_group()
28
+ context, active_profile, report = run_check(get_active_profile(), group=selected_group)
28
29
  ok = _is_check_ok(
29
30
  is_valid=report.is_valid,
30
31
  unknown_keys=report.unknown_keys,
@@ -37,6 +38,7 @@ def check_command() -> None:
37
38
  "command": "check",
38
39
  "data": {
39
40
  "active_profile": active_profile,
41
+ "selected_group": selected_group,
40
42
  "context": serialize_project_context(context),
41
43
  "report": serialize_resolution_report(report),
42
44
  },
@@ -48,6 +50,7 @@ def check_command() -> None:
48
50
 
49
51
  render_resolution_view(
50
52
  profile=active_profile,
53
+ group=selected_group,
51
54
  report=report,
52
55
  )
53
56
 
@@ -34,8 +34,12 @@ def explain_command(key: str = typer.Argument(...)) -> None:
34
34
  profile=active_profile,
35
35
  key=item.key,
36
36
  source=item.source,
37
+ raw_value=item.raw_value,
37
38
  value=item.value,
38
39
  masked=item.masked,
40
+ expansion_status=item.expansion_status,
41
+ expansion_refs=item.expansion_refs,
42
+ expansion_error=item.expansion_error.detail if item.expansion_error is not None else None,
39
43
  valid=item.valid,
40
44
  detail=item.detail,
41
45
  )
@@ -0,0 +1,38 @@
1
+ """Export command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import typer
8
+
9
+ from envctl.cli.decorators import handle_errors, text_output_only
10
+ from envctl.cli.presenters import render_export_output
11
+ from envctl.cli.runtime import get_active_profile, get_selected_group
12
+ from envctl.services.export_service import run_export
13
+
14
+
15
+ @handle_errors
16
+ @text_output_only("export")
17
+ def export_command(
18
+ format: Literal["shell", "dotenv"] = typer.Option(
19
+ "shell",
20
+ "--format",
21
+ help=(
22
+ "Choose the export format: 'shell' prints shell export lines, "
23
+ "'dotenv' prints KEY=value lines to stdout."
24
+ ),
25
+ ),
26
+ ) -> None:
27
+ """Print the resolved environment as shell export lines."""
28
+ _context, active_profile, rendered = run_export(
29
+ get_active_profile(),
30
+ format=format,
31
+ group=get_selected_group(),
32
+ )
33
+
34
+ if format == "dotenv":
35
+ print(rendered, end="")
36
+ return
37
+
38
+ render_export_output(profile=active_profile, rendered=rendered)
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from envctl.cli.decorators import handle_errors
6
6
  from envctl.cli.presenters import render_resolution_view
7
- from envctl.cli.runtime import get_active_profile, is_json_output
7
+ from envctl.cli.runtime import get_active_profile, get_selected_group, is_json_output
8
8
  from envctl.cli.serializers import (
9
9
  emit_json,
10
10
  serialize_project_context,
@@ -16,7 +16,8 @@ from envctl.services.inspect_service import run_inspect
16
16
  @handle_errors
17
17
  def inspect_command() -> None:
18
18
  """Inspect the resolved environment."""
19
- context, active_profile, report = run_inspect(get_active_profile())
19
+ selected_group = get_selected_group()
20
+ context, active_profile, report = run_inspect(get_active_profile(), group=selected_group)
20
21
 
21
22
  if is_json_output():
22
23
  emit_json(
@@ -25,6 +26,7 @@ def inspect_command() -> None:
25
26
  "command": "inspect",
26
27
  "data": {
27
28
  "active_profile": active_profile,
29
+ "selected_group": selected_group,
28
30
  "context": serialize_project_context(context),
29
31
  "report": serialize_resolution_report(report),
30
32
  },
@@ -34,5 +36,6 @@ def inspect_command() -> None:
34
36
 
35
37
  render_resolution_view(
36
38
  profile=active_profile,
39
+ group=selected_group,
37
40
  report=report,
38
41
  )
@@ -45,7 +45,9 @@ def remove_command(
45
45
  key=key,
46
46
  contract_path=result.repo_contract_path,
47
47
  removed_from_contract=result.removed_from_contract,
48
+ inspected_profiles=result.inspected_profiles,
48
49
  removed_from_profiles=result.removed_from_profiles,
50
+ missing_from_profiles=result.missing_from_profiles,
49
51
  affected_paths=result.affected_paths,
50
52
  repo_root=context.repo_root,
51
53
  )
@@ -5,7 +5,8 @@ from __future__ import annotations
5
5
  import typer
6
6
 
7
7
  from envctl.cli.decorators import handle_errors, text_output_only
8
- from envctl.cli.runtime import get_active_profile
8
+ from envctl.cli.presenters.run_presenter import render_run_warnings
9
+ from envctl.cli.runtime import get_active_profile, get_selected_group
9
10
  from envctl.services.run_service import run_command
10
11
 
11
12
  COMMAND_ARGUMENT = typer.Argument(...)
@@ -15,8 +16,10 @@ COMMAND_ARGUMENT = typer.Argument(...)
15
16
  @text_output_only("run")
16
17
  def run_command_cli(command: list[str] = COMMAND_ARGUMENT) -> None:
17
18
  """Run a child process with the resolved environment injected."""
18
- _context, _active_profile, exit_code = run_command(
19
+ _context, result = run_command(
19
20
  command,
20
21
  get_active_profile(),
22
+ group=get_selected_group(),
21
23
  )
22
- raise typer.Exit(code=exit_code)
24
+ render_run_warnings(result.warnings)
25
+ raise typer.Exit(code=result.exit_code)
@@ -0,0 +1,35 @@
1
+ """Sync command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from envctl.cli.decorators import handle_errors, requires_writable_runtime
10
+ from envctl.cli.presenters import render_sync_result
11
+ from envctl.cli.runtime import get_active_profile, get_selected_group
12
+ from envctl.services.sync_service import run_sync
13
+
14
+ OUTPUT_OPTION = typer.Option(
15
+ None,
16
+ "--output",
17
+ help=(
18
+ "Write the generated dotenv file to PATH instead of the default "
19
+ "profile-derived repo-local target."
20
+ ),
21
+ )
22
+
23
+
24
+ @handle_errors
25
+ @requires_writable_runtime("sync")
26
+ def sync_command(
27
+ output: Path | None = OUTPUT_OPTION,
28
+ ) -> None:
29
+ """Materialize the resolved environment into the repository env file."""
30
+ _context, active_profile, target_path = run_sync(
31
+ get_active_profile(),
32
+ output_path=output,
33
+ group=get_selected_group(),
34
+ )
35
+ render_sync_result(profile=active_profile, target_path=target_path)
@@ -32,6 +32,7 @@ from envctl.cli.presenters.resolution_presenter import (
32
32
  render_resolution,
33
33
  render_resolution_view,
34
34
  )
35
+ from envctl.cli.presenters.run_presenter import render_run_warnings
35
36
  from envctl.cli.presenters.status_presenter import (
36
37
  render_status,
37
38
  render_status_view,
@@ -71,6 +72,7 @@ __all__ = [
71
72
  "render_remove_result",
72
73
  "render_resolution",
73
74
  "render_resolution_view",
75
+ "render_run_warnings",
74
76
  "render_set_result",
75
77
  "render_status",
76
78
  "render_status_view",
@@ -74,8 +74,12 @@ def render_explain_value(
74
74
  profile: str,
75
75
  key: str,
76
76
  source: str,
77
+ raw_value: str | None,
77
78
  value: str,
78
79
  masked: bool,
80
+ expansion_status: str,
81
+ expansion_refs: tuple[str, ...],
82
+ expansion_error: str | None,
79
83
  valid: bool,
80
84
  detail: str | None,
81
85
  ) -> None:
@@ -85,7 +89,14 @@ def render_explain_value(
85
89
  print_kv("profile", profile)
86
90
  print_kv("key", key)
87
91
  print_kv("source", source)
92
+ if raw_value is not None:
93
+ print_kv("raw_value", raw_value)
88
94
  print_kv("value", shown_value)
95
+ print_kv("expansion_status", expansion_status)
96
+ if expansion_refs:
97
+ print_kv("expansion_refs", ", ".join(expansion_refs))
98
+ if expansion_error is not None:
99
+ print_kv("expansion_error", expansion_error)
89
100
  print_kv("valid", "yes" if valid else "no")
90
101
 
91
102
  if detail:
@@ -166,7 +177,9 @@ def render_remove_result(
166
177
  key: str,
167
178
  contract_path: Path,
168
179
  removed_from_contract: bool,
180
+ inspected_profiles: tuple[str, ...],
169
181
  removed_from_profiles: tuple[str, ...],
182
+ missing_from_profiles: tuple[str, ...],
170
183
  affected_paths: tuple[Path, ...],
171
184
  repo_root: Path,
172
185
  ) -> None:
@@ -178,6 +191,14 @@ def render_remove_result(
178
191
  "removed_from_profiles",
179
192
  ", ".join(removed_from_profiles) if removed_from_profiles else "none",
180
193
  )
194
+ print_kv(
195
+ "inspected_profiles",
196
+ ", ".join(inspected_profiles) if inspected_profiles else "none",
197
+ )
198
+ print_kv(
199
+ "missing_from_profiles",
200
+ ", ".join(missing_from_profiles) if missing_from_profiles else "none",
201
+ )
181
202
 
182
203
  if affected_paths:
183
204
  print_kv("affected_paths", ", ".join(str(path) for path in affected_paths))
@@ -54,7 +54,13 @@ def _render_resolved_values(report: ResolutionReport) -> None:
54
54
  item = report.values[key]
55
55
  shown_value = mask_value(item.value) if item.masked else item.value
56
56
  suffix = "" if item.valid else f" — invalid: {item.detail or 'unknown reason'}"
57
- typer.echo(f" {key} = {shown_value} ({item.source}){suffix}")
57
+ expansion_suffix = ""
58
+ if item.expansion_status == "expanded":
59
+ refs = ", ".join(item.expansion_refs)
60
+ expansion_suffix = f" [expanded{': ' + refs if refs else ''}]"
61
+ elif item.expansion_status == "error" and item.expansion_error is not None:
62
+ expansion_suffix = f" [expansion error: {item.expansion_error.kind}]"
63
+ typer.echo(f" {key} = {shown_value} ({item.source}){expansion_suffix}{suffix}")
58
64
 
59
65
 
60
66
  def render_resolution(report: ResolutionReport) -> None:
@@ -71,8 +77,11 @@ def render_resolution(report: ResolutionReport) -> None:
71
77
  def render_resolution_view(
72
78
  *,
73
79
  profile: str,
80
+ group: str | None,
74
81
  report: ResolutionReport,
75
82
  ) -> None:
76
83
  """Render one resolved environment view including the active profile."""
77
84
  print_kv("profile", profile)
85
+ if group is not None:
86
+ print_kv("group", group)
78
87
  render_resolution(report)
@@ -0,0 +1,11 @@
1
+ """Presenters for run command advisories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from envctl.utils.output import print_warning
6
+
7
+
8
+ def render_run_warnings(warnings: tuple[str, ...]) -> None:
9
+ """Render run command warnings."""
10
+ for warning in warnings:
11
+ print_warning(warning)
@@ -25,6 +25,9 @@ def build_remove_confirmation_message(key: str, plan: RemovePlan) -> str:
25
25
  if plan.present_in_other_profiles:
26
26
  lines.append(f"- also present in: {', '.join(plan.present_in_other_profiles)}")
27
27
 
28
+ if plan.absent_in_other_profiles:
29
+ lines.append(f"- not present in: {', '.join(plan.absent_in_other_profiles)}")
30
+
28
31
  return "\n".join(lines)
29
32
 
30
33
 
@@ -17,6 +17,7 @@ class CliState:
17
17
 
18
18
  output_format: OutputFormat = OutputFormat.TEXT
19
19
  profile: str = DEFAULT_PROFILE
20
+ group: str | None = None
20
21
 
21
22
 
22
23
  def set_cli_state(
@@ -24,9 +25,10 @@ def set_cli_state(
24
25
  *,
25
26
  output_format: OutputFormat,
26
27
  profile: str = DEFAULT_PROFILE,
28
+ group: str | None = None,
27
29
  ) -> None:
28
30
  """Persist the CLI state on the Typer/Click context."""
29
- ctx.obj = CliState(output_format=output_format, profile=profile)
31
+ ctx.obj = CliState(output_format=output_format, profile=profile, group=group)
30
32
 
31
33
 
32
34
  def get_cli_state() -> CliState:
@@ -52,6 +54,11 @@ def get_active_profile() -> str:
52
54
  return get_cli_state().profile
53
55
 
54
56
 
57
+ def get_selected_group() -> str | None:
58
+ """Return the active CLI group filter."""
59
+ return get_cli_state().group
60
+
61
+
55
62
  def get_command_path() -> str | None:
56
63
  """Return the current command path when available.
57
64
 
@@ -46,12 +46,24 @@ def serialize_project_context(context: ProjectContext) -> dict[str, Any]:
46
46
 
47
47
  def serialize_resolved_value(item: ResolvedValue) -> dict[str, Any]:
48
48
  """Serialize one resolved value."""
49
+ shown_raw_value = None if item.raw_value is None or item.masked else item.raw_value
49
50
  shown_value = mask_value(item.value) if item.masked else item.value
50
51
  return {
51
52
  "key": item.key,
53
+ "raw_value": shown_raw_value,
52
54
  "value": shown_value,
53
55
  "source": item.source,
54
56
  "masked": item.masked,
57
+ "expansion_status": item.expansion_status,
58
+ "expansion_refs": list(item.expansion_refs),
59
+ "expansion_error": (
60
+ {
61
+ "kind": item.expansion_error.kind,
62
+ "detail": item.expansion_error.detail,
63
+ }
64
+ if item.expansion_error is not None
65
+ else None
66
+ ),
55
67
  "valid": item.valid,
56
68
  "detail": item.detail,
57
69
  }