kctl-api 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Environment validation (doctor) commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Check, fix, and report on the development environment.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from kctl_api.core.callbacks import AppContext
|
|
15
|
+
from kctl_api.core.utils import find_project_root
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="doctor", help="Environment validation — check, fix, report.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_binary(name: str, version_flag: str = "--version") -> dict:
|
|
21
|
+
"""Check if a binary is available and get its version."""
|
|
22
|
+
path = shutil.which(name)
|
|
23
|
+
if not path:
|
|
24
|
+
return {"name": name, "ok": False, "version": None, "path": None, "issue": f"{name} not found in PATH"}
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run([name, version_flag], capture_output=True, text=True, timeout=5)
|
|
27
|
+
version = (result.stdout or result.stderr).strip().splitlines()[0][:80]
|
|
28
|
+
return {"name": name, "ok": True, "version": version, "path": path}
|
|
29
|
+
except Exception as e:
|
|
30
|
+
return {"name": name, "ok": False, "version": None, "path": path, "issue": str(e)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_python() -> dict:
|
|
34
|
+
"""Check Python version."""
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
version = sys.version.split()[0]
|
|
38
|
+
major, minor = sys.version_info[:2]
|
|
39
|
+
ok = (major, minor) >= (3, 12)
|
|
40
|
+
return {
|
|
41
|
+
"name": "python",
|
|
42
|
+
"ok": ok,
|
|
43
|
+
"version": version,
|
|
44
|
+
"path": sys.executable,
|
|
45
|
+
"issue": None if ok else f"Python 3.12+ required, got {version}",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_env_file() -> dict:
|
|
50
|
+
"""Check .env file exists and has required keys."""
|
|
51
|
+
root = find_project_root()
|
|
52
|
+
env_file = root / ".env"
|
|
53
|
+
example_file = root / ".env.example"
|
|
54
|
+
|
|
55
|
+
if not env_file.exists():
|
|
56
|
+
return {"name": ".env", "ok": False, "issue": f"{env_file} not found. Copy .env.example"}
|
|
57
|
+
if not example_file.exists():
|
|
58
|
+
return {"name": ".env", "ok": True, "version": "exists (no .env.example to compare)"}
|
|
59
|
+
|
|
60
|
+
# Find keys in example but missing in .env
|
|
61
|
+
example_keys = set()
|
|
62
|
+
env_keys = set()
|
|
63
|
+
|
|
64
|
+
for line in example_file.read_text().splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if line and not line.startswith("#") and "=" in line:
|
|
67
|
+
example_keys.add(line.split("=")[0].strip())
|
|
68
|
+
|
|
69
|
+
for line in env_file.read_text().splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if line and not line.startswith("#") and "=" in line:
|
|
72
|
+
env_keys.add(line.split("=")[0].strip())
|
|
73
|
+
|
|
74
|
+
missing = example_keys - env_keys
|
|
75
|
+
if missing:
|
|
76
|
+
return {
|
|
77
|
+
"name": ".env",
|
|
78
|
+
"ok": False,
|
|
79
|
+
"issue": f"Missing keys: {', '.join(sorted(missing)[:5])}{'...' if len(missing) > 5 else ''}",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {"name": ".env", "ok": True, "version": f"{len(env_keys)} keys configured"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _check_postgres_async(actx: AppContext) -> dict:
|
|
86
|
+
"""Check PostgreSQL connectivity."""
|
|
87
|
+
db_url = actx.database_url
|
|
88
|
+
if not db_url:
|
|
89
|
+
return {"name": "postgresql", "ok": False, "issue": "No database_url configured"}
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
from kctl_api.core.db import dispose_engine, execute_query
|
|
93
|
+
|
|
94
|
+
rows = await execute_query(db_url, "SELECT version()")
|
|
95
|
+
await dispose_engine()
|
|
96
|
+
version = rows[0].get("version", "")[:60] if rows else "unknown"
|
|
97
|
+
return {"name": "postgresql", "ok": True, "version": version}
|
|
98
|
+
except Exception as e:
|
|
99
|
+
return {"name": "postgresql", "ok": False, "issue": str(e)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _check_redis_async(actx: AppContext) -> dict:
|
|
103
|
+
"""Check Redis connectivity."""
|
|
104
|
+
redis_url = actx.redis_url
|
|
105
|
+
if not redis_url:
|
|
106
|
+
return {"name": "redis", "ok": False, "issue": "No redis_url configured"}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
from kctl_api.core.redis import close_redis, get_redis
|
|
110
|
+
|
|
111
|
+
client = get_redis(redis_url)
|
|
112
|
+
info = await client.info("server")
|
|
113
|
+
await close_redis()
|
|
114
|
+
version = info.get("redis_version", "unknown") if info else "unknown"
|
|
115
|
+
return {"name": "redis", "ok": True, "version": f"redis {version}"}
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return {"name": "redis", "ok": False, "issue": str(e)}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_all_checks(actx: AppContext) -> list[dict]:
|
|
121
|
+
checks: list[dict] = []
|
|
122
|
+
|
|
123
|
+
# Binaries
|
|
124
|
+
checks.append(_check_python())
|
|
125
|
+
checks.append(_check_binary("uv", "--version"))
|
|
126
|
+
checks.append(_check_binary("docker", "--version"))
|
|
127
|
+
checks.append(_check_binary("git", "--version"))
|
|
128
|
+
|
|
129
|
+
# Env file
|
|
130
|
+
checks.append(_check_env_file())
|
|
131
|
+
|
|
132
|
+
# Services (async)
|
|
133
|
+
checks.append(asyncio.run(_check_postgres_async(actx)))
|
|
134
|
+
checks.append(asyncio.run(_check_redis_async(actx)))
|
|
135
|
+
|
|
136
|
+
return checks
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# check
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
@app.command()
|
|
143
|
+
def check(ctx: typer.Context) -> None:
|
|
144
|
+
"""Check Python, uv, Docker, PostgreSQL, Redis, and .env configuration."""
|
|
145
|
+
actx: AppContext = ctx.obj
|
|
146
|
+
out = actx.output
|
|
147
|
+
|
|
148
|
+
out.info("Running environment checks ...")
|
|
149
|
+
checks = _run_all_checks(actx)
|
|
150
|
+
|
|
151
|
+
rows = [
|
|
152
|
+
[
|
|
153
|
+
c["name"],
|
|
154
|
+
"[green]ok[/green]" if c.get("ok") else "[red]fail[/red]",
|
|
155
|
+
c.get("version", ""),
|
|
156
|
+
c.get("issue", ""),
|
|
157
|
+
]
|
|
158
|
+
for c in checks
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
out.table(
|
|
162
|
+
title="Environment Check",
|
|
163
|
+
columns=[("Check", "bold"), ("Status", ""), ("Version", ""), ("Issue", "red")],
|
|
164
|
+
rows=rows,
|
|
165
|
+
data_for_json=checks,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
failed = [c for c in checks if not c.get("ok")]
|
|
169
|
+
if failed:
|
|
170
|
+
out.warn(f"{len(failed)} check(s) failed. Run: kctl-api doctor fix")
|
|
171
|
+
raise typer.Exit(1)
|
|
172
|
+
else:
|
|
173
|
+
out.success("All checks passed.")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# fix
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
@app.command()
|
|
180
|
+
def fix(ctx: typer.Context) -> None:
|
|
181
|
+
"""Auto-fix common environment issues."""
|
|
182
|
+
actx: AppContext = ctx.obj
|
|
183
|
+
out = actx.output
|
|
184
|
+
|
|
185
|
+
root = find_project_root()
|
|
186
|
+
checks = _run_all_checks(actx)
|
|
187
|
+
failed = [c for c in checks if not c.get("ok")]
|
|
188
|
+
|
|
189
|
+
if not failed:
|
|
190
|
+
out.success("Nothing to fix — all checks passed.")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
for check_item in failed:
|
|
194
|
+
name = check_item["name"]
|
|
195
|
+
issue = check_item.get("issue", "")
|
|
196
|
+
out.info(f"Fixing: {name} — {issue}")
|
|
197
|
+
|
|
198
|
+
if name == ".env":
|
|
199
|
+
env_example = root / ".env.example"
|
|
200
|
+
env_file = root / ".env"
|
|
201
|
+
if not env_file.exists() and env_example.exists():
|
|
202
|
+
import shutil as sh
|
|
203
|
+
|
|
204
|
+
sh.copy(env_example, env_file)
|
|
205
|
+
out.success("Copied .env.example → .env")
|
|
206
|
+
else:
|
|
207
|
+
out.warn(f"Manual fix required for .env: {issue}")
|
|
208
|
+
|
|
209
|
+
elif name == "uv":
|
|
210
|
+
out.info("Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
|
211
|
+
|
|
212
|
+
elif name == "docker":
|
|
213
|
+
out.info("Install Docker: https://docs.docker.com/engine/install/")
|
|
214
|
+
|
|
215
|
+
elif name == "python":
|
|
216
|
+
out.info("Install Python 3.12+: https://www.python.org/downloads/")
|
|
217
|
+
|
|
218
|
+
elif name in ("postgresql", "redis"):
|
|
219
|
+
out.info("Start services: kctl-api dev up")
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
out.warn(f"No automated fix for {name}. Issue: {issue}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# report
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
@app.command()
|
|
229
|
+
def report(ctx: typer.Context) -> None:
|
|
230
|
+
"""Generate a full diagnostic report."""
|
|
231
|
+
actx: AppContext = ctx.obj
|
|
232
|
+
out = actx.output
|
|
233
|
+
|
|
234
|
+
import datetime
|
|
235
|
+
import platform
|
|
236
|
+
import sys
|
|
237
|
+
|
|
238
|
+
out.header("Diagnostic Report")
|
|
239
|
+
out.text(f" Generated: {datetime.datetime.now(tz=datetime.UTC).isoformat()}")
|
|
240
|
+
out.text(f" Platform: {platform.platform()}")
|
|
241
|
+
out.text(f" Python: {sys.version}")
|
|
242
|
+
out.text("")
|
|
243
|
+
|
|
244
|
+
checks = _run_all_checks(actx)
|
|
245
|
+
passed = [c for c in checks if c.get("ok")]
|
|
246
|
+
failed = [c for c in checks if not c.get("ok")]
|
|
247
|
+
|
|
248
|
+
out.text(f" Checks: {len(checks)} total, {len(passed)} passed, {len(failed)} failed")
|
|
249
|
+
out.text("")
|
|
250
|
+
|
|
251
|
+
for c in checks:
|
|
252
|
+
status = "[green]PASS[/green]" if c.get("ok") else "[red]FAIL[/red]"
|
|
253
|
+
line = f" {status} {c['name']}"
|
|
254
|
+
if c.get("version"):
|
|
255
|
+
line += f" ({c['version']})"
|
|
256
|
+
if c.get("issue"):
|
|
257
|
+
line += f" — {c['issue']}"
|
|
258
|
+
out.text(line)
|
|
259
|
+
|
|
260
|
+
out.text("")
|
|
261
|
+
|
|
262
|
+
# Project info
|
|
263
|
+
root = find_project_root()
|
|
264
|
+
pyproject = root / "pyproject.toml"
|
|
265
|
+
if pyproject.exists():
|
|
266
|
+
try:
|
|
267
|
+
import tomllib
|
|
268
|
+
|
|
269
|
+
data = tomllib.loads(pyproject.read_text())
|
|
270
|
+
version = data.get("tool", {}).get("commitizen", {}).get("version", "unknown")
|
|
271
|
+
out.text(f" Project version: {version}")
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
out.text(f" Project root: {root}")
|
|
276
|
+
|
|
277
|
+
if actx.json_mode:
|
|
278
|
+
out.raw_json(
|
|
279
|
+
{
|
|
280
|
+
"platform": platform.platform(),
|
|
281
|
+
"python": sys.version,
|
|
282
|
+
"checks": checks,
|
|
283
|
+
"passed": len(passed),
|
|
284
|
+
"failed": len(failed),
|
|
285
|
+
}
|
|
286
|
+
)
|
kctl_api/commands/env.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Environment management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Validate, diff, template, and sync .env files across the monorepo.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pathlib
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_api.core.callbacks import AppContext
|
|
14
|
+
from kctl_api.core.utils import find_project_root
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="env", help="Environment management — validate, diff, template, sync.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_env_file(path: pathlib.Path) -> dict[str, str]:
|
|
20
|
+
"""Parse a .env file into a key-value dict, ignoring comments."""
|
|
21
|
+
result: dict[str, str] = {}
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return result
|
|
24
|
+
for line in path.read_text().splitlines():
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line or line.startswith("#"):
|
|
27
|
+
continue
|
|
28
|
+
if "=" in line:
|
|
29
|
+
key, _, value = line.partition("=")
|
|
30
|
+
result[key.strip()] = value.strip()
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _find_env_files(root: pathlib.Path) -> list[pathlib.Path]:
|
|
35
|
+
"""Find all .env files in the monorepo (apps/)."""
|
|
36
|
+
found = []
|
|
37
|
+
for env_file in root.rglob(".env"):
|
|
38
|
+
# Skip .env inside .venv, node_modules, etc.
|
|
39
|
+
parts = env_file.parts
|
|
40
|
+
if any(p in parts for p in (".venv", "node_modules", "__pycache__", ".git")):
|
|
41
|
+
continue
|
|
42
|
+
found.append(env_file)
|
|
43
|
+
return sorted(found)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# validate
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
@app.command()
|
|
50
|
+
def validate(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
env_file: Annotated[str | None, typer.Option("--file", "-f", help="Path to .env file.")] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Validate .env against .env.example — show missing and extra keys."""
|
|
55
|
+
actx: AppContext = ctx.obj
|
|
56
|
+
out = actx.output
|
|
57
|
+
|
|
58
|
+
root = find_project_root()
|
|
59
|
+
env_path = pathlib.Path(env_file) if env_file else root / ".env"
|
|
60
|
+
example_path = env_path.parent / ".env.example"
|
|
61
|
+
|
|
62
|
+
if not env_path.exists():
|
|
63
|
+
out.error(f".env not found: {env_path}")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
if not example_path.exists():
|
|
67
|
+
out.warn(f"No .env.example found at {example_path} — cannot validate.")
|
|
68
|
+
env_keys = _parse_env_file(env_path)
|
|
69
|
+
out.info(f"{env_path.name} has {len(env_keys)} keys.")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
env_keys = _parse_env_file(env_path)
|
|
73
|
+
example_keys = _parse_env_file(example_path)
|
|
74
|
+
|
|
75
|
+
missing = sorted(example_keys.keys() - env_keys.keys())
|
|
76
|
+
extra = sorted(env_keys.keys() - example_keys.keys())
|
|
77
|
+
shared = sorted(example_keys.keys() & env_keys.keys())
|
|
78
|
+
|
|
79
|
+
issues: list[dict] = []
|
|
80
|
+
|
|
81
|
+
for key in missing:
|
|
82
|
+
issues.append({"key": key, "status": "MISSING", "note": "In .env.example but not in .env"})
|
|
83
|
+
|
|
84
|
+
# Check for empty values on required keys
|
|
85
|
+
for key in shared:
|
|
86
|
+
if not env_keys[key] and example_keys.get(key):
|
|
87
|
+
issues.append({"key": key, "status": "EMPTY", "note": "Key exists but has no value"})
|
|
88
|
+
|
|
89
|
+
if not issues and not extra:
|
|
90
|
+
out.success(f"Validation passed — {len(shared)} keys match, {len(extra)} extra keys in .env.")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if issues:
|
|
94
|
+
rows = [[i["key"], i["status"], i["note"]] for i in issues]
|
|
95
|
+
out.table(
|
|
96
|
+
title=f"Validation Issues ({len(issues)})",
|
|
97
|
+
columns=[("Key", "bold"), ("Status", "red"), ("Note", "")],
|
|
98
|
+
rows=rows,
|
|
99
|
+
data_for_json=issues,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if extra:
|
|
103
|
+
out.info(f"Extra keys in .env (not in .env.example): {', '.join(extra[:10])}")
|
|
104
|
+
|
|
105
|
+
if issues:
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# diff
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
@app.command()
|
|
113
|
+
def diff(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
file_a: Annotated[str | None, typer.Argument(help="First .env file path.")] = None,
|
|
116
|
+
file_b: Annotated[str | None, typer.Argument(help="Second .env file path.")] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Diff two .env files, or .env vs .env.example if no args given."""
|
|
119
|
+
actx: AppContext = ctx.obj
|
|
120
|
+
out = actx.output
|
|
121
|
+
|
|
122
|
+
root = find_project_root()
|
|
123
|
+
|
|
124
|
+
path_a = pathlib.Path(file_a) if file_a else root / ".env.example"
|
|
125
|
+
path_b = pathlib.Path(file_b) if file_b else root / ".env"
|
|
126
|
+
|
|
127
|
+
if not path_a.exists():
|
|
128
|
+
out.error(f"File not found: {path_a}")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
if not path_b.exists():
|
|
131
|
+
out.error(f"File not found: {path_b}")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
keys_a = _parse_env_file(path_a)
|
|
135
|
+
keys_b = _parse_env_file(path_b)
|
|
136
|
+
|
|
137
|
+
all_keys = sorted(keys_a.keys() | keys_b.keys())
|
|
138
|
+
diffs: list[dict] = []
|
|
139
|
+
|
|
140
|
+
for key in all_keys:
|
|
141
|
+
in_a = key in keys_a
|
|
142
|
+
in_b = key in keys_b
|
|
143
|
+
val_a = keys_a.get(key, "")
|
|
144
|
+
val_b = keys_b.get(key, "")
|
|
145
|
+
|
|
146
|
+
if not in_a:
|
|
147
|
+
diffs.append({"key": key, "status": "added", "a": "", "b": val_b})
|
|
148
|
+
elif not in_b:
|
|
149
|
+
diffs.append({"key": key, "status": "removed", "a": val_a, "b": ""})
|
|
150
|
+
elif val_a != val_b:
|
|
151
|
+
diffs.append({"key": key, "status": "changed", "a": val_a[:40], "b": val_b[:40]})
|
|
152
|
+
|
|
153
|
+
if not diffs:
|
|
154
|
+
out.success(f"No differences between {path_a.name} and {path_b.name}.")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
rows = [
|
|
158
|
+
[
|
|
159
|
+
d["key"],
|
|
160
|
+
{
|
|
161
|
+
"added": "[green]added[/green]",
|
|
162
|
+
"removed": "[red]removed[/red]",
|
|
163
|
+
"changed": "[yellow]changed[/yellow]",
|
|
164
|
+
}.get(d["status"], d["status"]),
|
|
165
|
+
d["a"],
|
|
166
|
+
d["b"],
|
|
167
|
+
]
|
|
168
|
+
for d in diffs
|
|
169
|
+
]
|
|
170
|
+
out.table(
|
|
171
|
+
title=f"Diff: {path_a.name} vs {path_b.name} ({len(diffs)} differences)",
|
|
172
|
+
columns=[("Key", "bold"), ("Status", ""), (path_a.name, ""), (path_b.name, "")],
|
|
173
|
+
rows=rows,
|
|
174
|
+
data_for_json=diffs,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# template
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
@app.command()
|
|
182
|
+
def template(
|
|
183
|
+
ctx: typer.Context,
|
|
184
|
+
source: Annotated[str | None, typer.Option("--source", "-s", help="Source .env file.")] = None,
|
|
185
|
+
output: Annotated[str | None, typer.Option("--output", "-o", help="Output path (default: .env.example).")] = None,
|
|
186
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite if exists.")] = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Generate .env.example from .env by stripping values."""
|
|
189
|
+
actx: AppContext = ctx.obj
|
|
190
|
+
out = actx.output
|
|
191
|
+
|
|
192
|
+
root = find_project_root()
|
|
193
|
+
source_path = pathlib.Path(source) if source else root / ".env"
|
|
194
|
+
output_path = pathlib.Path(output) if output else source_path.parent / ".env.example"
|
|
195
|
+
|
|
196
|
+
if not source_path.exists():
|
|
197
|
+
out.error(f"Source .env not found: {source_path}")
|
|
198
|
+
raise typer.Exit(1)
|
|
199
|
+
|
|
200
|
+
if output_path.exists() and not force:
|
|
201
|
+
confirm = typer.confirm(f"Overwrite existing {output_path}?", default=False)
|
|
202
|
+
if not confirm:
|
|
203
|
+
out.info("Cancelled.")
|
|
204
|
+
raise typer.Exit(0)
|
|
205
|
+
|
|
206
|
+
lines: list[str] = []
|
|
207
|
+
for line in source_path.read_text().splitlines():
|
|
208
|
+
stripped = line.strip()
|
|
209
|
+
if not stripped or stripped.startswith("#"):
|
|
210
|
+
lines.append(line)
|
|
211
|
+
elif "=" in stripped:
|
|
212
|
+
key = stripped.split("=")[0].strip()
|
|
213
|
+
lines.append(f"{key}=")
|
|
214
|
+
else:
|
|
215
|
+
lines.append(line)
|
|
216
|
+
|
|
217
|
+
output_path.write_text("\n".join(lines) + "\n")
|
|
218
|
+
out.success(
|
|
219
|
+
f"Template written to {output_path} ({len([ln for ln in lines if '=' in ln and not ln.startswith('#')])} keys)"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# sync
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
@app.command()
|
|
227
|
+
def sync(
|
|
228
|
+
ctx: typer.Context,
|
|
229
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be synced without writing.")] = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Sync .env keys across monorepo apps — add missing keys from root .env.example."""
|
|
232
|
+
actx: AppContext = ctx.obj
|
|
233
|
+
out = actx.output
|
|
234
|
+
|
|
235
|
+
root = find_project_root()
|
|
236
|
+
root_example = root / ".env.example"
|
|
237
|
+
|
|
238
|
+
if not root_example.exists():
|
|
239
|
+
out.error(f"Root .env.example not found: {root_example}")
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
|
|
242
|
+
root_keys = _parse_env_file(root_example)
|
|
243
|
+
env_files = _find_env_files(root)
|
|
244
|
+
|
|
245
|
+
out.info(f"Root .env.example: {len(root_keys)} keys")
|
|
246
|
+
out.info(f"Found {len(env_files)} .env files in monorepo")
|
|
247
|
+
|
|
248
|
+
sync_report: list[dict] = []
|
|
249
|
+
|
|
250
|
+
for env_file in env_files:
|
|
251
|
+
example_file = env_file.parent / ".env.example"
|
|
252
|
+
if not example_file.exists():
|
|
253
|
+
sync_report.append({"file": str(env_file), "status": "no .env.example", "added": []})
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
app_keys = _parse_env_file(example_file)
|
|
257
|
+
missing_from_app = sorted(root_keys.keys() - app_keys.keys())
|
|
258
|
+
|
|
259
|
+
if not missing_from_app:
|
|
260
|
+
sync_report.append({"file": str(env_file), "status": "in sync", "added": []})
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if dry_run:
|
|
264
|
+
sync_report.append({"file": str(env_file), "status": "needs sync", "added": missing_from_app})
|
|
265
|
+
out.warn(f"Would add to {example_file.relative_to(root)}: {', '.join(missing_from_app)}")
|
|
266
|
+
else:
|
|
267
|
+
# Append missing keys (with empty values) to example
|
|
268
|
+
with open(example_file, "a") as f:
|
|
269
|
+
f.write("\n# Synced from root .env.example\n")
|
|
270
|
+
for key in missing_from_app:
|
|
271
|
+
f.write(f"{key}=\n")
|
|
272
|
+
sync_report.append({"file": str(env_file), "status": "synced", "added": missing_from_app})
|
|
273
|
+
out.success(f"Synced {len(missing_from_app)} keys to {example_file.relative_to(root)}")
|
|
274
|
+
|
|
275
|
+
rows = [
|
|
276
|
+
[
|
|
277
|
+
str(pathlib.Path(r["file"]).relative_to(root)),
|
|
278
|
+
r["status"],
|
|
279
|
+
str(len(r["added"])),
|
|
280
|
+
", ".join(r["added"][:5]),
|
|
281
|
+
]
|
|
282
|
+
for r in sync_report
|
|
283
|
+
]
|
|
284
|
+
out.table(
|
|
285
|
+
title="Sync Report",
|
|
286
|
+
columns=[("File", ""), ("Status", ""), ("Added", ""), ("Keys", "")],
|
|
287
|
+
rows=rows,
|
|
288
|
+
data_for_json=sync_report,
|
|
289
|
+
)
|