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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. 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
+ )
@@ -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
+ )