envyy 0.1.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.
envyy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.3
2
+ Name: envyy
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Trevor Wang
6
+ Author-email: Trevor Wang <stephen.trevor.wong@outlook.com>
7
+ Requires-Dist: pyyaml>=6.0.2
8
+ Requires-Dist: watchfiles>=0.21.0
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # envyy
13
+
14
+ Fast, schema-driven environment configuration compiler and orchestrator.
15
+
16
+ `envyy` manages multi-stage application configurations through a clean three-tier architecture:
17
+ * **`env.yaml`**: Local, private configuration catalog containing secrets, stage matrices, and shared constants (ignored by Git).
18
+ * **`env.schema.yaml`**: Public, scrubbed team contract that documents keys, comments, and structure without leaking secrets (committed to Git).
19
+ * **`.env`**: Active, single-file runtime payload compiled for a specific deployment stage.
20
+
21
+ ---
22
+
23
+ ## Installation & Quick Run
24
+
25
+ Run instantly without global installation using `uvx`:
26
+
27
+ ```bash
28
+ uvx envyy --help
29
+ ```
30
+
31
+ Or install globally into your environment via `uv`:
32
+
33
+ ```bash
34
+ uv tool install envyy
35
+ envy --help
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Core Concepts
41
+
42
+ ### 1. Root Configuration (`env.yaml`)
43
+ Your local source of truth. It contains three top-level sections:
44
+
45
+ ```yaml
46
+ # Stage values safe to commit to env.schema.yaml
47
+ populate: dev
48
+
49
+ # Shared constants referenced via ${TOKEN} (never emitted directly)
50
+ localvars:
51
+ BASE_DOMAIN: "example.com"
52
+ DEFAULT_PORT: 8080
53
+
54
+ # Environment variables per stage
55
+ environment:
56
+ PORT:
57
+ _comment: "HTTP server listener port"
58
+ dev: 3000
59
+ staging: "${DEFAULT_PORT}"
60
+ prod: "${DEFAULT_PORT}"
61
+
62
+ API_URL:
63
+ _comment: "Backend API gateway"
64
+ dev: "http://localhost:8080"
65
+ staging: "https://staging-api.${BASE_DOMAIN}"
66
+ prod: "https://api.${BASE_DOMAIN}"
67
+
68
+ DATABASE_URL:
69
+ _comment: "Database connection string. Scrubbed from schema."
70
+ secret: true
71
+ dev: "postgresql://postgres:postgres@localhost:5432/app_dev"
72
+ prod: "postgresql://admin:secret@db.prod.internal:5432/app_prod"
73
+ ```
74
+
75
+ ### 2. Omission Rules During Compilation
76
+ * **Omitted:** If a variable has no entry for a given stage, or is set to explicit null (`~`, `null`, or bare `stage:`), it is omitted from `.env` entirely. No orphaned comments are written.
77
+ * **Preserved:** Explicit empty strings (`""`), `0`, and `false` are recognized as deliberate operational overrides and emitted as `KEY=`, `KEY=0`, or `KEY=false`.
78
+
79
+ ---
80
+
81
+ ## CLI Usage
82
+
83
+ ### `init` — Scaffold or Seed Configuration
84
+
85
+ Initialize a starter template or bootstrap from an existing `.env` file:
86
+
87
+ ```bash
88
+ # Initialize a starter env.yaml (or copies existing env.schema.yaml)
89
+ envy init
90
+
91
+ # Seed env.yaml directly from an existing .env file into dev
92
+ envy init .env.dev dev
93
+
94
+ # Seed directly into production and specify schema populate stage
95
+ envy init .env.prod prod --populate dev
96
+ ```
97
+
98
+ ---
99
+
100
+ ### `gen` — Compile .env and Sync Schema
101
+
102
+ Compiles `.env` according to the values specified for the stage and updates `env.schema.yaml`. Redundant disk writes are bypassed if file contents have not changed.
103
+
104
+ ```bash
105
+ # Compile for development
106
+ envy gen dev
107
+
108
+ # Compile for staging
109
+ envy gen staging
110
+
111
+ # Compile for production
112
+ envy gen prod
113
+ ```
114
+
115
+ ---
116
+
117
+ ### `import` — Ingest External `.env` Files
118
+
119
+ Merge keys from any `.env` file into a target stage inside `env.yaml` without destroying existing comments or other stages:
120
+
121
+ ```bash
122
+ # Import a local development file into the dev stage
123
+ envy import .env.local dev
124
+
125
+ # Ingest production values into the prod stage
126
+ envy import /path/to/production.env prod
127
+ ```
128
+
129
+ ---
130
+
131
+ ### `dedupe` — Identify Duplicates & Suggest `localvars`
132
+
133
+ Scans stage values across `env.yaml` to detect duplicates, suggesting sanitized, underscore-slugged `${TOKEN}` identifiers:
134
+ * Distinguishes full URLs with protocols as `ORIGIN_*`.
135
+ * Distinguishes bare hostnames/domains as `HOST_*`.
136
+ * Automatically disambiguates stage-specific keys (e.g., `SHARED_API_KEYS_DEV` vs. `SHARED_API_KEYS_PROD`).
137
+
138
+ ```bash
139
+ # Inspect potential duplicate values
140
+ envy dedupe
141
+
142
+ # Inspect with custom occurrence threshold (default: 2)
143
+ envy dedupe --min-count 3
144
+
145
+ # Automatically apply suggested localvars and refactor env.yaml
146
+ envy dedupe --apply
147
+ ```
148
+
149
+ ---
150
+
151
+ ### `watch` — Live Development Sync
152
+
153
+ Monitors `env.yaml` for edits and recompiles `.env` and `env.schema.yaml` on save with built-in debouncing:
154
+
155
+ ```bash
156
+ # Watch and keep .env compiled for dev
157
+ envy watch --stage dev
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Recommended `.gitignore`
163
+
164
+ Ensure private catalogs and generated environments stay out of version control while committing the schema contract:
165
+
166
+ ```gitignore
167
+ # Envy local configurations
168
+ env.yaml
169
+ .env
170
+ *.tmp
171
+
172
+ # DO NOT ignore the team schema contract
173
+ !env.schema.yaml
174
+ ```
175
+
176
+ ---
177
+
178
+ ## License
179
+
180
+ MIT
envyy-0.1.0/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # envyy
2
+
3
+ Fast, schema-driven environment configuration compiler and orchestrator.
4
+
5
+ `envyy` manages multi-stage application configurations through a clean three-tier architecture:
6
+ * **`env.yaml`**: Local, private configuration catalog containing secrets, stage matrices, and shared constants (ignored by Git).
7
+ * **`env.schema.yaml`**: Public, scrubbed team contract that documents keys, comments, and structure without leaking secrets (committed to Git).
8
+ * **`.env`**: Active, single-file runtime payload compiled for a specific deployment stage.
9
+
10
+ ---
11
+
12
+ ## Installation & Quick Run
13
+
14
+ Run instantly without global installation using `uvx`:
15
+
16
+ ```bash
17
+ uvx envyy --help
18
+ ```
19
+
20
+ Or install globally into your environment via `uv`:
21
+
22
+ ```bash
23
+ uv tool install envyy
24
+ envy --help
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Core Concepts
30
+
31
+ ### 1. Root Configuration (`env.yaml`)
32
+ Your local source of truth. It contains three top-level sections:
33
+
34
+ ```yaml
35
+ # Stage values safe to commit to env.schema.yaml
36
+ populate: dev
37
+
38
+ # Shared constants referenced via ${TOKEN} (never emitted directly)
39
+ localvars:
40
+ BASE_DOMAIN: "example.com"
41
+ DEFAULT_PORT: 8080
42
+
43
+ # Environment variables per stage
44
+ environment:
45
+ PORT:
46
+ _comment: "HTTP server listener port"
47
+ dev: 3000
48
+ staging: "${DEFAULT_PORT}"
49
+ prod: "${DEFAULT_PORT}"
50
+
51
+ API_URL:
52
+ _comment: "Backend API gateway"
53
+ dev: "http://localhost:8080"
54
+ staging: "https://staging-api.${BASE_DOMAIN}"
55
+ prod: "https://api.${BASE_DOMAIN}"
56
+
57
+ DATABASE_URL:
58
+ _comment: "Database connection string. Scrubbed from schema."
59
+ secret: true
60
+ dev: "postgresql://postgres:postgres@localhost:5432/app_dev"
61
+ prod: "postgresql://admin:secret@db.prod.internal:5432/app_prod"
62
+ ```
63
+
64
+ ### 2. Omission Rules During Compilation
65
+ * **Omitted:** If a variable has no entry for a given stage, or is set to explicit null (`~`, `null`, or bare `stage:`), it is omitted from `.env` entirely. No orphaned comments are written.
66
+ * **Preserved:** Explicit empty strings (`""`), `0`, and `false` are recognized as deliberate operational overrides and emitted as `KEY=`, `KEY=0`, or `KEY=false`.
67
+
68
+ ---
69
+
70
+ ## CLI Usage
71
+
72
+ ### `init` — Scaffold or Seed Configuration
73
+
74
+ Initialize a starter template or bootstrap from an existing `.env` file:
75
+
76
+ ```bash
77
+ # Initialize a starter env.yaml (or copies existing env.schema.yaml)
78
+ envy init
79
+
80
+ # Seed env.yaml directly from an existing .env file into dev
81
+ envy init .env.dev dev
82
+
83
+ # Seed directly into production and specify schema populate stage
84
+ envy init .env.prod prod --populate dev
85
+ ```
86
+
87
+ ---
88
+
89
+ ### `gen` — Compile .env and Sync Schema
90
+
91
+ Compiles `.env` according to the values specified for the stage and updates `env.schema.yaml`. Redundant disk writes are bypassed if file contents have not changed.
92
+
93
+ ```bash
94
+ # Compile for development
95
+ envy gen dev
96
+
97
+ # Compile for staging
98
+ envy gen staging
99
+
100
+ # Compile for production
101
+ envy gen prod
102
+ ```
103
+
104
+ ---
105
+
106
+ ### `import` — Ingest External `.env` Files
107
+
108
+ Merge keys from any `.env` file into a target stage inside `env.yaml` without destroying existing comments or other stages:
109
+
110
+ ```bash
111
+ # Import a local development file into the dev stage
112
+ envy import .env.local dev
113
+
114
+ # Ingest production values into the prod stage
115
+ envy import /path/to/production.env prod
116
+ ```
117
+
118
+ ---
119
+
120
+ ### `dedupe` — Identify Duplicates & Suggest `localvars`
121
+
122
+ Scans stage values across `env.yaml` to detect duplicates, suggesting sanitized, underscore-slugged `${TOKEN}` identifiers:
123
+ * Distinguishes full URLs with protocols as `ORIGIN_*`.
124
+ * Distinguishes bare hostnames/domains as `HOST_*`.
125
+ * Automatically disambiguates stage-specific keys (e.g., `SHARED_API_KEYS_DEV` vs. `SHARED_API_KEYS_PROD`).
126
+
127
+ ```bash
128
+ # Inspect potential duplicate values
129
+ envy dedupe
130
+
131
+ # Inspect with custom occurrence threshold (default: 2)
132
+ envy dedupe --min-count 3
133
+
134
+ # Automatically apply suggested localvars and refactor env.yaml
135
+ envy dedupe --apply
136
+ ```
137
+
138
+ ---
139
+
140
+ ### `watch` — Live Development Sync
141
+
142
+ Monitors `env.yaml` for edits and recompiles `.env` and `env.schema.yaml` on save with built-in debouncing:
143
+
144
+ ```bash
145
+ # Watch and keep .env compiled for dev
146
+ envy watch --stage dev
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Recommended `.gitignore`
152
+
153
+ Ensure private catalogs and generated environments stay out of version control while committing the schema contract:
154
+
155
+ ```gitignore
156
+ # Envy local configurations
157
+ env.yaml
158
+ .env
159
+ *.tmp
160
+
161
+ # DO NOT ignore the team schema contract
162
+ !env.schema.yaml
163
+ ```
164
+
165
+ ---
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "envyy"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pyyaml>=6.0.2",
9
+ "watchfiles>=0.21.0",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "Trevor Wang"
14
+ email = "stephen.trevor.wong@outlook.com"
15
+
16
+ [project.scripts]
17
+ envyy = "envyy.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.13.6,<0.14.0"]
21
+ build-backend = "uv_build"
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "envyy"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Trevor Wang", email = "stephen.trevor.wong@outlook.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "pyyaml>=6.0.2",
12
+ "watchfiles>=0.21.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ envyy = "envyy.cli:main"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.13.6,<0.14.0"]
20
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,708 @@
1
+ import argparse
2
+ import io
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ import yaml
10
+
11
+ CONFIG_FILE = Path("env.yaml")
12
+ SCHEMA_FILE = Path("env.schema.yaml")
13
+ ENV_FILE = Path(".env")
14
+
15
+ STARTER_TEMPLATE = """# Stages whose values can safely be committed to Git (e.g. dev)
16
+ populate: dev
17
+
18
+ # -------------------------------------------------------------------------
19
+ # Local Variables (Internal Interpolation Only)
20
+ # Shared constants referenced via ${TOKEN} inside the environment section.
21
+ # These are never emitted into .env directly.
22
+ # -------------------------------------------------------------------------
23
+ localvars:
24
+ BASE_DOMAIN: "example.com"
25
+ DEFAULT_PORT: 8080
26
+
27
+ # -------------------------------------------------------------------------
28
+ # Environment Variables
29
+ # Emitted into the active .env based on the requested --stage (default: dev).
30
+ # -------------------------------------------------------------------------
31
+ environment:
32
+ APP_ENV:
33
+ _comment: "Runtime execution mode"
34
+ dev: "development"
35
+ staging: "staging"
36
+ prod: "production"
37
+
38
+ PORT:
39
+ _comment: "HTTP server listener port"
40
+ dev: 3000
41
+ staging: "${DEFAULT_PORT}"
42
+ prod: "${DEFAULT_PORT}"
43
+
44
+ API_URL:
45
+ _comment: "Backend API endpoint"
46
+ dev: "http://localhost:8080"
47
+ staging: "https://staging-api.${BASE_DOMAIN}"
48
+ prod: "https://api.${BASE_DOMAIN}"
49
+
50
+ JWT_SECRET:
51
+ _comment: "Authentication signing secret. Scrubbed even in dev."
52
+ secret: true
53
+ dev: "local_dev_jwt_secret"
54
+ staging: "staging_vault_jwt_secret"
55
+ prod: "prod_vault_jwt_secret"
56
+ """
57
+
58
+ TOKEN_PATTERN = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
59
+
60
+
61
+ class IndentDumper(yaml.SafeDumper):
62
+ """YAML dumper enforcing 2-space indentation on nested blocks."""
63
+
64
+ def increase_indent(self, flow: bool = False, indentless: bool = False):
65
+ return super().increase_indent(flow=False, indentless=False)
66
+
67
+
68
+ def load_or_init_catalog(path: Path) -> dict[str, Any]:
69
+ """Loads existing env.yaml or initializes a baseline structure."""
70
+ if not path.is_file():
71
+ return {
72
+ "populate": "dev",
73
+ "localvars": {},
74
+ "environment": {},
75
+ }
76
+ with open(path, "r", encoding="utf-8") as f:
77
+ data = yaml.safe_load(f)
78
+ if not isinstance(data, dict):
79
+ raise ValueError(f"'{path}' must define a top-level YAML mapping.")
80
+ if "environment" not in data or not isinstance(data["environment"], dict):
81
+ data["environment"] = {}
82
+ if "localvars" not in data or not isinstance(data["localvars"], dict):
83
+ data["localvars"] = {}
84
+ return data
85
+
86
+
87
+ def parse_env_file(path: Path) -> list[tuple[str, Any, str | None]]:
88
+ """Parses a .env file, extracting (key, value, optional_comment)."""
89
+ if not path.is_file():
90
+ raise FileNotFoundError(f"Source environment file '{path}' not found.")
91
+
92
+ entries: list[tuple[str, Any, str | None]] = []
93
+ pending_comments: list[str] = []
94
+
95
+ with open(path, "r", encoding="utf-8") as f:
96
+ for line in f:
97
+ stripped = line.strip()
98
+
99
+ if not stripped:
100
+ pending_comments.clear()
101
+ continue
102
+
103
+ if stripped.startswith("#"):
104
+ comment_text = stripped.lstrip("#").strip()
105
+ if comment_text:
106
+ pending_comments.append(comment_text)
107
+ continue
108
+
109
+ if stripped.startswith("export "):
110
+ stripped = stripped[7:].strip()
111
+
112
+ if "=" not in stripped:
113
+ continue
114
+
115
+ key, raw_val = stripped.split("=", 1)
116
+ key = key.strip()
117
+ raw_val = raw_val.strip()
118
+
119
+ if not key:
120
+ continue
121
+
122
+ if (raw_val.startswith('"') and raw_val.endswith('"')) or (
123
+ raw_val.startswith("'") and raw_val.endswith("'")
124
+ ):
125
+ val: Any = raw_val[1:-1]
126
+ elif raw_val.lower() == "true":
127
+ val = True
128
+ elif raw_val.lower() == "false":
129
+ val = False
130
+ else:
131
+ try:
132
+ val = int(raw_val)
133
+ except ValueError:
134
+ val = raw_val
135
+
136
+ comment = " ".join(pending_comments) if pending_comments else None
137
+ pending_comments.clear()
138
+ entries.append((key, val, comment))
139
+
140
+ return entries
141
+
142
+
143
+ def sanitize_token_slug(text: str) -> str:
144
+ """Converts any string to an uppercase slug containing only letters, numbers, and underscores."""
145
+ slug = re.sub(r"[^A-Za-z0-9]+", "_", text)
146
+ slug = re.sub(r"_+", "_", slug).strip("_")
147
+ return slug.upper()
148
+
149
+
150
+ def suggest_token_name(value: Any, occurrences: list[tuple[str, str]]) -> str:
151
+ """Generates an uppercase identifier based on value semantics."""
152
+ val_str = str(value).strip()
153
+ var_names = [var for var, _ in occurrences]
154
+
155
+ if isinstance(value, int) or val_str.isdigit():
156
+ for name in var_names:
157
+ if "PORT" in name.upper():
158
+ return "DEFAULT_PORT"
159
+ return sanitize_token_slug(f"SHARED_VAL_{val_str}")
160
+
161
+ if re.match(r"^https?://", val_str, re.IGNORECASE):
162
+ parsed = urlparse(val_str)
163
+ host_stem = parsed.hostname or parsed.netloc.split(":")[0]
164
+ return sanitize_token_slug(f"ORIGIN_{host_stem}")
165
+
166
+ domain_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9-]*\.)+[a-zA-Z]{2,}(:[0-9]+)?$"
167
+ if re.match(domain_pattern, val_str):
168
+ host_stem = val_str.split(":")[0]
169
+ return sanitize_token_slug(f"HOST_{host_stem}")
170
+
171
+ if val_str in ("localhost", "127.0.0.1"):
172
+ return "HOST_LOCALHOST"
173
+
174
+ clean_names = [re.sub(r"^(DEV_|PROD_|STAGING_)", "", n) for n in var_names]
175
+ common_stem = clean_names[0]
176
+ return sanitize_token_slug(f"SHARED_{common_stem}")
177
+
178
+
179
+ def resolve_token(value: Any, localvars: dict[str, Any], context_label: str) -> Any:
180
+ """Substitutes ${KEY} placeholders using flat localvars definitions."""
181
+ if not isinstance(value, str):
182
+ return value
183
+
184
+ exact_match = re.fullmatch(r"\$\{([A-Za-z0-9_]+)\}", value.strip())
185
+ if exact_match:
186
+ token = exact_match.group(1)
187
+ if token not in localvars:
188
+ raise KeyError(
189
+ f"Undefined localvar '${{{token}}}' referenced in {context_label}"
190
+ )
191
+ return localvars[token]
192
+
193
+ def _replace(match: re.Match) -> str:
194
+ token = match.group(1)
195
+ if token not in localvars:
196
+ raise KeyError(
197
+ f"Undefined localvar '${{{token}}}' referenced in {context_label}"
198
+ )
199
+ return str(localvars[token])
200
+
201
+ return TOKEN_PATTERN.sub(_replace, value)
202
+
203
+
204
+ def format_env_value(val: Any) -> str:
205
+ """Formats Python primitives into standard .env format."""
206
+ if isinstance(val, bool):
207
+ return str(val).lower()
208
+ if val is None:
209
+ return ""
210
+ val_str = str(val)
211
+ if any(char in val_str for char in (" ", "#", "\n", "\t")):
212
+ escaped = val_str.replace('"', '\\"')
213
+ return f'"{escaped}"'
214
+ return val_str
215
+
216
+
217
+ def build_schema_data(data: dict[str, Any]) -> dict[str, Any]:
218
+ """Builds the sanitized structural schema for Git tracking."""
219
+ populate_rules = data.get("populate", "dev")
220
+ allowed_stages = (
221
+ {populate_rules}
222
+ if isinstance(populate_rules, str)
223
+ else set(populate_rules or [])
224
+ )
225
+
226
+ schema: dict[str, Any] = {"populate": populate_rules}
227
+
228
+ localvars = data.get("localvars", {})
229
+ schema["localvars"] = {k: "" for k in localvars.keys()}
230
+
231
+ environment = data.get("environment", {})
232
+ schema_env: dict[str, Any] = {}
233
+
234
+ for var_name, var_spec in environment.items():
235
+ if not isinstance(var_spec, dict):
236
+ schema_env[var_name] = ""
237
+ continue
238
+
239
+ var_schema: dict[str, Any] = {}
240
+ is_secret = var_spec.get("secret", False) is True
241
+
242
+ for field_key, field_val in var_spec.items():
243
+ if field_key.startswith("_") or field_key == "secret":
244
+ var_schema[field_key] = field_val
245
+ elif is_secret:
246
+ var_schema[field_key] = ""
247
+ elif isinstance(field_val, str) and TOKEN_PATTERN.search(field_val):
248
+ var_schema[field_key] = field_val
249
+ elif field_key in allowed_stages:
250
+ var_schema[field_key] = field_val
251
+ else:
252
+ var_schema[field_key] = ""
253
+
254
+ schema_env[var_name] = var_schema
255
+
256
+ schema["environment"] = schema_env
257
+ return schema
258
+
259
+
260
+ def generate_env_content(
261
+ environment: dict[str, Any], localvars: dict[str, Any], stage: str
262
+ ) -> str:
263
+ """Generates the full in-memory .env content for the requested stage."""
264
+ lines = [
265
+ f"# Auto-generated configuration for stage: {stage}",
266
+ "# DO NOT EDIT DIRECTLY. Manage via env.yaml\n",
267
+ ]
268
+
269
+ for var_name, var_spec in environment.items():
270
+ if not isinstance(var_spec, dict):
271
+ continue
272
+
273
+ # If the stage key doesn't exist or is explicitly null (~, None, bare 'stage:') -> OMIT
274
+ if stage not in var_spec or var_spec[stage] is None:
275
+ continue
276
+
277
+ comment = var_spec.get("_comment")
278
+ if comment:
279
+ lines.append(f"# {comment}")
280
+
281
+ raw_val = var_spec[stage]
282
+ resolved_val = resolve_token(raw_val, localvars, f"{var_name} [{stage}]")
283
+ lines.append(f"{var_name}={format_env_value(resolved_val)}\n")
284
+
285
+ return "\n".join(lines).strip() + "\n"
286
+
287
+
288
+ def atomic_write_if_changed(target_path: Path, new_content: str) -> bool:
289
+ """Writes to file only when content has changed, bypassing redundant mtime updates."""
290
+ if target_path.exists():
291
+ try:
292
+ if target_path.read_text(encoding="utf-8") == new_content:
293
+ return False
294
+ except Exception:
295
+ pass
296
+
297
+ tmp_path = target_path.with_suffix(f"{target_path.suffix}.tmp")
298
+ with open(tmp_path, "w", encoding="utf-8") as f:
299
+ f.write(new_content)
300
+ tmp_path.replace(target_path)
301
+ return True
302
+
303
+
304
+ def run_pipeline(stage: str) -> bool:
305
+ """Executes interpolation, change detection, and atomic writing."""
306
+ try:
307
+ data = load_or_init_catalog(CONFIG_FILE)
308
+ except Exception as e:
309
+ print(f"\033[31m✖ Error loading {CONFIG_FILE}:\033[0m {e}")
310
+ return False
311
+
312
+ localvars = data.get("localvars", {})
313
+ environment = data.get("environment", {})
314
+
315
+ try:
316
+ env_content = generate_env_content(environment, localvars, stage)
317
+ env_updated = atomic_write_if_changed(ENV_FILE, env_content)
318
+ except KeyError as e:
319
+ print(f"\033[31m✖ Interpolation Error:\033[0m {e}")
320
+ return False
321
+ except Exception as e:
322
+ print(f"\033[31m✖ Error generating {ENV_FILE.name}:\033[0m {e}")
323
+ return False
324
+
325
+ try:
326
+ schema_data = build_schema_data(data)
327
+ buffer = io.StringIO()
328
+ buffer.write("# Auto-generated from env.yaml. DO NOT EDIT DIRECTLY.\n")
329
+ buffer.write(
330
+ "# Commit this file to Git as the team configuration contract.\n\n"
331
+ )
332
+ yaml.dump(
333
+ schema_data,
334
+ buffer,
335
+ Dumper=IndentDumper,
336
+ sort_keys=False,
337
+ allow_unicode=True,
338
+ )
339
+ schema_content = buffer.getvalue()
340
+ schema_updated = atomic_write_if_changed(SCHEMA_FILE, schema_content)
341
+ except Exception as e:
342
+ print(f"\033[31m✖ Error generating {SCHEMA_FILE.name}:\033[0m {e}")
343
+ return False
344
+
345
+ actions = []
346
+ if env_updated:
347
+ actions.append(
348
+ f"updated \033[32m{ENV_FILE.name}\033[0m (stage: \033[36m{stage}\033[0m)"
349
+ )
350
+ else:
351
+ actions.append(f"\033[90m{ENV_FILE.name} unchanged\033[0m")
352
+
353
+ if schema_updated:
354
+ actions.append(f"updated \033[32m{SCHEMA_FILE.name}\033[0m")
355
+ else:
356
+ actions.append(f"\033[90m{SCHEMA_FILE.name} unchanged\033[0m")
357
+
358
+ print(" | ".join(actions))
359
+ return True
360
+
361
+
362
+ def cmd_init(
363
+ file_path: Path | None = None, stage: str | None = None, populate: str = "dev"
364
+ ):
365
+ """Initializes env.yaml from a starter template or an existing .env file."""
366
+ if CONFIG_FILE.exists():
367
+ print(f"\033[33mWarning:\033[0m {CONFIG_FILE.name} already exists.")
368
+ if file_path and stage:
369
+ print(
370
+ f"To merge into an existing file, use: envy import {file_path} {stage}"
371
+ )
372
+ return
373
+
374
+ # Seed directly from an existing .env file
375
+ if file_path and stage:
376
+ try:
377
+ entries = parse_env_file(file_path)
378
+ except FileNotFoundError:
379
+ print(
380
+ f"\033[31m✖ Error:\033[0m Source environment file '{file_path}' not found."
381
+ )
382
+ print(f"\033[36mUsage:\033[0m envy init [file stage] [--populate POPULATE]")
383
+ sys.exit(1)
384
+ except Exception as e:
385
+ print(f"\033[31m✖ Error reading '{file_path}':\033[0m {e}")
386
+ sys.exit(1)
387
+
388
+ print(
389
+ f"Initializing {CONFIG_FILE.name} from '{file_path}' for stage '{stage}'..."
390
+ )
391
+ data = {
392
+ "populate": populate,
393
+ "localvars": {},
394
+ "environment": {},
395
+ }
396
+
397
+ for key, val, comment in entries:
398
+ var_entry: dict[str, Any] = {}
399
+ if comment:
400
+ var_entry["_comment"] = comment
401
+ var_entry[stage] = val
402
+ data["environment"][key] = var_entry
403
+
404
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
405
+ yaml.dump(data, f, Dumper=IndentDumper, sort_keys=False, allow_unicode=True)
406
+
407
+ print(
408
+ f"\033[32m✔ Initialized '{CONFIG_FILE.name}' [stage: {stage}, populate: {populate}] with {len(entries)} variables.\033[0m"
409
+ )
410
+ run_pipeline(stage)
411
+ return
412
+
413
+ # Bare init: Fall back to existing schema or the default template
414
+ if SCHEMA_FILE.exists():
415
+ print(
416
+ f"Found existing {SCHEMA_FILE.name}. Initializing {CONFIG_FILE.name} from schema..."
417
+ )
418
+ CONFIG_FILE.write_text(
419
+ SCHEMA_FILE.read_text(encoding="utf-8"), encoding="utf-8"
420
+ )
421
+ else:
422
+ print(f"Creating starter {CONFIG_FILE.name}...")
423
+ template = STARTER_TEMPLATE
424
+ if populate != "dev":
425
+ template = re.sub(
426
+ r"^populate:\s*\w+",
427
+ f"populate: {populate}",
428
+ template,
429
+ flags=re.MULTILINE,
430
+ )
431
+ CONFIG_FILE.write_text(template, encoding="utf-8")
432
+
433
+ run_pipeline("dev")
434
+
435
+
436
+ def cmd_import(file_path: Path, stage: str):
437
+ """Imports variables from a .env file into env.yaml under the designated stage."""
438
+ try:
439
+ entries = parse_env_file(file_path)
440
+ except FileNotFoundError:
441
+ print(
442
+ f"\033[31m✖ Error:\033[0m Source environment file '{file_path}' not found."
443
+ )
444
+ print(f"\033[36mUsage:\033[0m envy import <file> <stage>")
445
+ print(f" Example: envy import .env.dev {file_path}")
446
+ sys.exit(1)
447
+ except Exception as e:
448
+ print(f"\033[31m✖ Error reading '{file_path}':\033[0m {e}")
449
+ sys.exit(1)
450
+
451
+ if not entries:
452
+ print(
453
+ f"\033[33mWarning:\033[0m No variable assignments found in '{file_path}'."
454
+ )
455
+ return
456
+
457
+ data = load_or_init_catalog(CONFIG_FILE)
458
+ env_dict = data["environment"]
459
+
460
+ added = 0
461
+ updated = 0
462
+
463
+ for key, val, comment in entries:
464
+ if key not in env_dict or not isinstance(env_dict[key], dict):
465
+ var_entry: dict[str, Any] = {}
466
+ if comment:
467
+ var_entry["_comment"] = comment
468
+ var_entry[stage] = val
469
+ env_dict[key] = var_entry
470
+ added += 1
471
+ else:
472
+ target_entry = env_dict[key]
473
+ if comment and "_comment" not in target_entry:
474
+ target_entry["_comment"] = comment
475
+ target_entry[stage] = val
476
+ updated += 1
477
+
478
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
479
+ yaml.dump(data, f, Dumper=IndentDumper, sort_keys=False, allow_unicode=True)
480
+
481
+ print(
482
+ f"\033[32m✔ Imported into '{CONFIG_FILE.name}' [stage: {stage}]:\033[0m {added} added, {updated} updated."
483
+ )
484
+ run_pipeline(stage)
485
+
486
+
487
+ def cmd_dedupe(apply: bool, min_count: int, stage: str):
488
+ """Scans environment section for duplicate values and suggests localvars."""
489
+ data = load_or_init_catalog(CONFIG_FILE)
490
+ environment = data.get("environment", {})
491
+ localvars = data.setdefault("localvars", {})
492
+
493
+ value_map: dict[Any, list[tuple[str, str]]] = {}
494
+
495
+ for var_name, var_spec in environment.items():
496
+ if not isinstance(var_spec, dict):
497
+ continue
498
+ for s_key, s_val in var_spec.items():
499
+ if s_key.startswith("_") or s_key == "secret":
500
+ continue
501
+ if s_val in ("", None) or isinstance(s_val, bool):
502
+ continue
503
+ if isinstance(s_val, str) and TOKEN_PATTERN.search(s_val):
504
+ continue
505
+
506
+ value_map.setdefault(s_val, []).append((var_name, s_key))
507
+
508
+ duplicates = {
509
+ val: occs for val, occs in value_map.items() if len(occs) >= min_count
510
+ }
511
+
512
+ if not duplicates:
513
+ print(
514
+ f"\033[32m✔ No duplicate values found\033[0m (threshold: >= {min_count} occurrences)."
515
+ )
516
+ return
517
+
518
+ print(f"\033[33mFound {len(duplicates)} candidate value(s) for localvars:\033[0m\n")
519
+
520
+ suggested_localvars: dict[str, Any] = {}
521
+ replacements: list[tuple[str, str, str, Any]] = []
522
+
523
+ for val, occs in duplicates.items():
524
+ base_token = suggest_token_name(val, occs)
525
+
526
+ stages = {s_key for _, s_key in occs}
527
+ if len(stages) == 1:
528
+ stage_suffix = next(iter(stages)).upper()
529
+ if not base_token.endswith(f"_{stage_suffix}"):
530
+ base_token = f"{base_token}_{stage_suffix}"
531
+
532
+ token = base_token
533
+ counter = 1
534
+
535
+ all_claimed_tokens = {**localvars, **suggested_localvars}
536
+ while token in all_claimed_tokens and all_claimed_tokens[token] != val:
537
+ token = f"{base_token}_{counter}"
538
+ counter += 1
539
+
540
+ suggested_localvars[token] = val
541
+ print(
542
+ f"• \033[36m${{{token}}}\033[0m = \033[32m{repr(val)}\033[0m ({len(occs)} occurrences)"
543
+ )
544
+ for var_name, s_key in occs:
545
+ print(f" - {var_name} [{s_key}]")
546
+ replacements.append((var_name, s_key, token, val))
547
+ print()
548
+
549
+ if not apply:
550
+ print("Suggested \033[1mlocalvars:\033[0m additions:")
551
+ for t, v in suggested_localvars.items():
552
+ print(f" {t}: {repr(v)}")
553
+ print("\nRun with \033[36m--apply\033[0m to rewrite env.yaml automatically.")
554
+ return
555
+
556
+ for token, val in suggested_localvars.items():
557
+ localvars[token] = val
558
+
559
+ for var_name, s_key, token, _ in replacements:
560
+ environment[var_name][s_key] = f"${{{token}}}"
561
+
562
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
563
+ yaml.dump(data, f, Dumper=IndentDumper, sort_keys=False, allow_unicode=True)
564
+
565
+ print(
566
+ f"\033[32m✔ Applied {len(suggested_localvars)} localvars to {CONFIG_FILE.name}!\033[0m"
567
+ )
568
+ run_pipeline(stage)
569
+
570
+
571
+ def cmd_watch(stage: str):
572
+ """Watches env.yaml for changes with debounced execution."""
573
+ from watchfiles import watch
574
+
575
+ print(
576
+ f"\033[36m⚡ Watching {CONFIG_FILE.name} for stage '{stage}' (Ctrl+C to quit)...\033[0m"
577
+ )
578
+ run_pipeline(stage)
579
+
580
+ try:
581
+ for _ in watch(CONFIG_FILE, debounce=150):
582
+ print(f"\033[33m[Change detected]\033[0m ", end="")
583
+ run_pipeline(stage)
584
+ except KeyboardInterrupt:
585
+ print("\nWatcher stopped.")
586
+
587
+
588
+ def main():
589
+ parser = argparse.ArgumentParser(
590
+ prog="envy",
591
+ description="Centralized environment manager producing single .env and schema files.",
592
+ )
593
+ subparsers = parser.add_subparsers(dest="command")
594
+
595
+ # init
596
+ init_parser = subparsers.add_parser(
597
+ "init",
598
+ help=f"Initialize {CONFIG_FILE.name} (bare template or from a file + stage)",
599
+ )
600
+ init_parser.add_argument(
601
+ "seed",
602
+ nargs="*",
603
+ help="Optional: <file> <stage> to seed env.yaml from an existing file",
604
+ )
605
+ init_parser.add_argument(
606
+ "--populate",
607
+ "-p",
608
+ default="dev",
609
+ help="Stage values safe to commit in env.schema.yaml (default: dev)",
610
+ )
611
+
612
+ # gen (aliased as generate)
613
+ gen_parser = subparsers.add_parser(
614
+ "gen",
615
+ aliases=["generate"],
616
+ help="Generate .env according to values specified by stage and sync schema",
617
+ description="Generate .env according to values specified by stage.",
618
+ )
619
+ gen_parser.add_argument(
620
+ "stage",
621
+ type=str,
622
+ help="Target stage to extract values from (e.g. dev, staging, prod)",
623
+ )
624
+ # watch
625
+ watch_parser = subparsers.add_parser(
626
+ "watch",
627
+ help=f"Watch {CONFIG_FILE.name} and recompile on save",
628
+ )
629
+ watch_parser.add_argument(
630
+ "--stage",
631
+ "-s",
632
+ default="dev",
633
+ help="Target deployment stage to watch and compile. Default: dev",
634
+ )
635
+
636
+ # import: two positional arguments (file and stage)
637
+ import_parser = subparsers.add_parser(
638
+ "import",
639
+ help="Import an existing .env file into a stage",
640
+ )
641
+ import_parser.add_argument(
642
+ "file",
643
+ type=Path,
644
+ help="Path to the .env file to import",
645
+ )
646
+ import_parser.add_argument(
647
+ "stage",
648
+ type=str,
649
+ help="Target stage to import into (e.g. dev, staging, prod)",
650
+ )
651
+
652
+ # dedupe
653
+ dedupe_parser = subparsers.add_parser(
654
+ "dedupe",
655
+ help="Find duplicate values in environment and suggest localvars",
656
+ )
657
+ dedupe_parser.add_argument(
658
+ "--stage",
659
+ "-s",
660
+ default="dev",
661
+ help="Target stage to compile after refactoring. Default: dev",
662
+ )
663
+ dedupe_parser.add_argument(
664
+ "--apply",
665
+ action="store_true",
666
+ help="Automatically write suggested tokens to env.yaml and replace duplicates",
667
+ )
668
+ dedupe_parser.add_argument(
669
+ "--min-count",
670
+ type=int,
671
+ default=2,
672
+ help="Minimum number of occurrences to consider a duplicate (default: 2)",
673
+ )
674
+
675
+ args = parser.parse_args()
676
+
677
+ if args.command == "init":
678
+ file_path = None
679
+ stage = None
680
+
681
+ if len(args.seed) == 1:
682
+ init_parser.error(
683
+ "When providing a file, you must also specify the stage: envy init <file> <stage>"
684
+ )
685
+ elif len(args.seed) == 2:
686
+ file_path = Path(args.seed[0])
687
+ stage = args.seed[1]
688
+ elif len(args.seed) > 2:
689
+ init_parser.error(
690
+ "Too many arguments. Usage: envy init [file stage] [--populate POPULATE]"
691
+ )
692
+
693
+ cmd_init(file_path=file_path, stage=stage, populate=args.populate)
694
+ elif args.command in ("gen", "generate"):
695
+ success = run_pipeline(args.stage)
696
+ sys.exit(0 if success else 1)
697
+ elif args.command == "watch":
698
+ cmd_watch(args.stage)
699
+ elif args.command == "import":
700
+ cmd_import(args.file, args.stage)
701
+ elif args.command == "dedupe":
702
+ cmd_dedupe(args.apply, args.min_count, args.stage)
703
+ else:
704
+ parser.print_help()
705
+
706
+
707
+ if __name__ == "__main__":
708
+ main()