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 +180 -0
- envyy-0.1.0/README.md +169 -0
- envyy-0.1.0/pyproject.toml +21 -0
- envyy-0.1.0/pyproject.toml.orig +20 -0
- envyy-0.1.0/src/envyy/__init__.py +0 -0
- envyy-0.1.0/src/envyy/cli.py +708 -0
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()
|