kctl-react 0.6.2__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_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Lint and type-check commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_react.core.analyzers import read_tsconfig_strict
|
|
13
|
+
from kctl_react.core.callbacks import AppContext
|
|
14
|
+
from kctl_react.core.discovery import get_app_dir
|
|
15
|
+
from kctl_react.core.exceptions import CommandError
|
|
16
|
+
from kctl_react.core.runner import run, run_turbo
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Lint, type-check, and format code.")
|
|
19
|
+
|
|
20
|
+
_SUBCOMMANDS = {"format", "strict-check", "tsconfig-audit", "conventions"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Core logic functions (shared by callback dispatch and @app.command stubs)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_strict_check(actx: AppContext) -> None:
|
|
29
|
+
"""Check that every app has strict: true in its tsconfig.json."""
|
|
30
|
+
out = actx.output
|
|
31
|
+
root = actx.project_root
|
|
32
|
+
|
|
33
|
+
rows: list[list[str]] = []
|
|
34
|
+
json_data: list[dict[str, str]] = []
|
|
35
|
+
any_not_strict = False
|
|
36
|
+
|
|
37
|
+
for app_name in actx.app_names:
|
|
38
|
+
app_dir = get_app_dir(root, app_name)
|
|
39
|
+
is_strict = read_tsconfig_strict(app_dir)
|
|
40
|
+
status = "strict" if is_strict else "[red]NOT strict[/red]"
|
|
41
|
+
rows.append([app_name, status])
|
|
42
|
+
json_data.append({"app": app_name, "strict": str(is_strict).lower()})
|
|
43
|
+
if not is_strict:
|
|
44
|
+
any_not_strict = True
|
|
45
|
+
|
|
46
|
+
out.table(
|
|
47
|
+
"TypeScript Strict Mode",
|
|
48
|
+
[("App", "cyan"), ("Status", "green")],
|
|
49
|
+
rows,
|
|
50
|
+
data_for_json=json_data,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if any_not_strict:
|
|
54
|
+
out.error("Some apps are missing strict: true in tsconfig.json")
|
|
55
|
+
raise typer.Exit(1) from None
|
|
56
|
+
|
|
57
|
+
out.success("All apps have strict: true")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_tsconfig_audit(actx: AppContext) -> None:
|
|
61
|
+
"""Audit tsconfig.json settings for strict and jsx across all apps."""
|
|
62
|
+
out = actx.output
|
|
63
|
+
root = actx.project_root
|
|
64
|
+
|
|
65
|
+
rows: list[list[str]] = []
|
|
66
|
+
json_data: list[dict[str, str]] = []
|
|
67
|
+
any_issues = False
|
|
68
|
+
|
|
69
|
+
for app_name in actx.app_names:
|
|
70
|
+
app_dir = get_app_dir(root, app_name)
|
|
71
|
+
tsconfig_path = app_dir / "tsconfig.json"
|
|
72
|
+
|
|
73
|
+
if not tsconfig_path.exists():
|
|
74
|
+
rows.append([app_name, "[red]MISSING[/red]", "tsconfig.json not found"])
|
|
75
|
+
json_data.append({"app": app_name, "status": "MISSING", "issues": "tsconfig.json not found"})
|
|
76
|
+
any_issues = True
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
text = tsconfig_path.read_text()
|
|
81
|
+
# Strip single-line // comments (tsconfig allows them)
|
|
82
|
+
text = re.sub(r"//.*", "", text)
|
|
83
|
+
data = json.loads(text)
|
|
84
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
85
|
+
rows.append([app_name, "[red]INVALID[/red]", f"Parse error: {exc}"])
|
|
86
|
+
json_data.append({"app": app_name, "status": "INVALID", "issues": f"Parse error: {exc}"})
|
|
87
|
+
any_issues = True
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
compiler_opts = data.get("compilerOptions", {})
|
|
91
|
+
issues: list[str] = []
|
|
92
|
+
|
|
93
|
+
if not compiler_opts.get("strict", False):
|
|
94
|
+
issues.append("strict: true missing")
|
|
95
|
+
if compiler_opts.get("jsx") != "react-jsx":
|
|
96
|
+
issues.append(f'jsx: expected "react-jsx", got {compiler_opts.get("jsx")!r}')
|
|
97
|
+
|
|
98
|
+
if issues:
|
|
99
|
+
issues_str = "; ".join(issues)
|
|
100
|
+
rows.append([app_name, "[yellow]ISSUES[/yellow]", issues_str])
|
|
101
|
+
json_data.append({"app": app_name, "status": "ISSUES", "issues": issues_str})
|
|
102
|
+
any_issues = True
|
|
103
|
+
else:
|
|
104
|
+
rows.append([app_name, "[green]OK[/green]", ""])
|
|
105
|
+
json_data.append({"app": app_name, "status": "OK", "issues": ""})
|
|
106
|
+
|
|
107
|
+
out.table(
|
|
108
|
+
"tsconfig.json Audit",
|
|
109
|
+
[("App", "cyan"), ("Status", "green"), ("Issues", "yellow")],
|
|
110
|
+
rows,
|
|
111
|
+
data_for_json=json_data,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if any_issues:
|
|
115
|
+
out.error("Some apps have tsconfig.json issues")
|
|
116
|
+
raise typer.Exit(1) from None
|
|
117
|
+
|
|
118
|
+
out.success("All apps have consistent tsconfig.json settings")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run_conventions(actx: AppContext, app_name: str) -> None:
|
|
122
|
+
"""Check a single app for convention violations."""
|
|
123
|
+
out = actx.output
|
|
124
|
+
root = actx.project_root
|
|
125
|
+
|
|
126
|
+
actx.validate_app(app_name)
|
|
127
|
+
|
|
128
|
+
src_dir = get_app_dir(root, app_name) / "src"
|
|
129
|
+
|
|
130
|
+
rows: list[list[str]] = []
|
|
131
|
+
json_data: list[dict[str, str]] = []
|
|
132
|
+
|
|
133
|
+
_SKIP_DIRS = {"generated", "node_modules"}
|
|
134
|
+
|
|
135
|
+
def _iter_ts_files(base: Path) -> list[Path]:
|
|
136
|
+
files: list[Path] = []
|
|
137
|
+
for p in base.rglob("*.ts"):
|
|
138
|
+
if not any(part in _SKIP_DIRS for part in p.parts):
|
|
139
|
+
files.append(p)
|
|
140
|
+
for p in base.rglob("*.tsx"):
|
|
141
|
+
if not any(part in _SKIP_DIRS for part in p.parts):
|
|
142
|
+
files.append(p)
|
|
143
|
+
return files
|
|
144
|
+
|
|
145
|
+
if src_dir.is_dir():
|
|
146
|
+
for file_path in _iter_ts_files(src_dir):
|
|
147
|
+
try:
|
|
148
|
+
lines = file_path.read_text().splitlines()
|
|
149
|
+
except OSError:
|
|
150
|
+
continue
|
|
151
|
+
rel = str(file_path.relative_to(get_app_dir(root, app_name)))
|
|
152
|
+
for line_num, line in enumerate(lines, start=1):
|
|
153
|
+
if '"use client"' in line or "'use client'" in line:
|
|
154
|
+
rows.append([rel, str(line_num), "use client directive", line.strip()])
|
|
155
|
+
json_data.append(
|
|
156
|
+
{"file": rel, "line": str(line_num), "issue": "use client directive", "code": line.strip()}
|
|
157
|
+
)
|
|
158
|
+
if re.search(r'from\s+["\'](\.\./){2,}', line):
|
|
159
|
+
rows.append([rel, str(line_num), "deep relative import", line.strip()])
|
|
160
|
+
json_data.append(
|
|
161
|
+
{"file": rel, "line": str(line_num), "issue": "deep relative import", "code": line.strip()}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if rows:
|
|
165
|
+
out.table(
|
|
166
|
+
f"Convention Issues: {app_name}",
|
|
167
|
+
[("File", "cyan"), ("Line", "yellow"), ("Issue", "red"), ("Code", "dim")],
|
|
168
|
+
rows,
|
|
169
|
+
data_for_json=json_data,
|
|
170
|
+
)
|
|
171
|
+
out.error(f"{len(rows)} convention issue(s) found in {app_name}")
|
|
172
|
+
raise typer.Exit(1) from None
|
|
173
|
+
|
|
174
|
+
out.success(f"No convention issues found in {app_name}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Typer commands
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.callback(invoke_without_command=True)
|
|
183
|
+
def lint(
|
|
184
|
+
ctx: typer.Context,
|
|
185
|
+
app_name: Annotated[str | None, typer.Option("--app", "-a", help="App name to lint (omit for all apps)")] = None,
|
|
186
|
+
fix: Annotated[bool, typer.Option("--fix", "-f", help="Auto-fix issues.")] = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Run ESLint + TypeScript type-check.
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
kctl-react lint # Lint all apps
|
|
192
|
+
kctl-react lint --app sfa # Lint SFA only
|
|
193
|
+
kctl-react lint --app sfa --fix
|
|
194
|
+
kctl-react lint strict-check
|
|
195
|
+
kctl-react lint tsconfig-audit
|
|
196
|
+
kctl-react lint conventions sfa
|
|
197
|
+
"""
|
|
198
|
+
if ctx.invoked_subcommand is not None:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
actx: AppContext = ctx.obj
|
|
202
|
+
out = actx.output
|
|
203
|
+
root = actx.project_root
|
|
204
|
+
|
|
205
|
+
if app_name:
|
|
206
|
+
actx.validate_app(app_name)
|
|
207
|
+
|
|
208
|
+
target = app_name or "all apps"
|
|
209
|
+
failed = False
|
|
210
|
+
|
|
211
|
+
# ESLint
|
|
212
|
+
out.info(f"Running ESLint on {target}...")
|
|
213
|
+
try:
|
|
214
|
+
run_turbo("lint", root, filter_app=app_name, capture=False, timeout=300)
|
|
215
|
+
out.success("ESLint passed")
|
|
216
|
+
except CommandError:
|
|
217
|
+
out.error("ESLint failed")
|
|
218
|
+
failed = True
|
|
219
|
+
except Exception as e:
|
|
220
|
+
out.error(f"ESLint error: {e}")
|
|
221
|
+
failed = True
|
|
222
|
+
|
|
223
|
+
# TypeScript type-check
|
|
224
|
+
out.info(f"Running type-check on {target}...")
|
|
225
|
+
try:
|
|
226
|
+
run_turbo("type-check", root, filter_app=app_name, capture=False, timeout=300)
|
|
227
|
+
out.success("Type-check passed")
|
|
228
|
+
except CommandError:
|
|
229
|
+
out.error("Type-check failed")
|
|
230
|
+
failed = True
|
|
231
|
+
except Exception as e:
|
|
232
|
+
out.error(f"Type-check error: {e}")
|
|
233
|
+
failed = True
|
|
234
|
+
|
|
235
|
+
if failed:
|
|
236
|
+
raise typer.Exit(1) from None
|
|
237
|
+
|
|
238
|
+
out.success(f"All quality checks passed: {target}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command()
|
|
242
|
+
def format(
|
|
243
|
+
ctx: typer.Context,
|
|
244
|
+
check: Annotated[bool, typer.Option("--check", help="Check only, don't fix.")] = False,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Run Prettier on the codebase."""
|
|
247
|
+
actx: AppContext = ctx.obj
|
|
248
|
+
out = actx.output
|
|
249
|
+
root = actx.project_root
|
|
250
|
+
|
|
251
|
+
if check:
|
|
252
|
+
out.info("Checking formatting...")
|
|
253
|
+
cmd = ["pnpm", "format-check"]
|
|
254
|
+
else:
|
|
255
|
+
out.info("Formatting code...")
|
|
256
|
+
cmd = ["pnpm", "format"]
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
run(cmd, cwd=root, capture=False, timeout=120)
|
|
260
|
+
out.success("Formatting OK")
|
|
261
|
+
except CommandError:
|
|
262
|
+
out.error("Formatting issues found")
|
|
263
|
+
raise typer.Exit(1) from None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command("strict-check")
|
|
267
|
+
def strict_check(ctx: typer.Context) -> None:
|
|
268
|
+
"""Verify all apps have TypeScript strict mode enabled.
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
kctl-react lint strict-check
|
|
272
|
+
kctl-react --json lint strict-check
|
|
273
|
+
"""
|
|
274
|
+
actx: AppContext = ctx.obj
|
|
275
|
+
_run_strict_check(actx)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command("tsconfig-audit")
|
|
279
|
+
def tsconfig_audit(ctx: typer.Context) -> None:
|
|
280
|
+
"""Audit tsconfig.json settings for consistency across all apps.
|
|
281
|
+
|
|
282
|
+
Checks that each app has strict: true and jsx: react-jsx.
|
|
283
|
+
|
|
284
|
+
Examples:
|
|
285
|
+
kctl-react lint tsconfig-audit
|
|
286
|
+
"""
|
|
287
|
+
actx: AppContext = ctx.obj
|
|
288
|
+
_run_tsconfig_audit(actx)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command()
|
|
292
|
+
def conventions(
|
|
293
|
+
ctx: typer.Context,
|
|
294
|
+
app_name: Annotated[str, typer.Argument(help="App name to check")],
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Check project conventions for an app.
|
|
297
|
+
|
|
298
|
+
Scans .ts/.tsx files for anti-patterns:
|
|
299
|
+
- 'use client' directive (Next.js pattern, wrong for Vite)
|
|
300
|
+
- Deep relative imports (../../) — should use @/ alias
|
|
301
|
+
|
|
302
|
+
Examples:
|
|
303
|
+
kctl-react lint conventions sfa
|
|
304
|
+
"""
|
|
305
|
+
actx: AppContext = ctx.obj
|
|
306
|
+
_run_conventions(actx, app_name)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Maintenance commands: health-report, cleanup, dr-status, count-test-files, deps-sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_react.core.callbacks import AppContext
|
|
13
|
+
from kctl_react.core.discovery import get_app_dir
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Maintenance utilities for the React monorepo.")
|
|
16
|
+
|
|
17
|
+
# Artifact directories to clean up
|
|
18
|
+
_CLEANUP_TARGETS = [
|
|
19
|
+
"dist",
|
|
20
|
+
".turbo",
|
|
21
|
+
"coverage",
|
|
22
|
+
Path("node_modules") / ".cache",
|
|
23
|
+
Path("node_modules") / ".vite",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _count_test_files(app_dir: Path) -> int:
|
|
28
|
+
"""Count .test.ts and .test.tsx files under <app_dir>/src."""
|
|
29
|
+
src = app_dir / "src"
|
|
30
|
+
if not src.is_dir():
|
|
31
|
+
return 0
|
|
32
|
+
return len(list(src.rglob("*.test.ts")) + list(src.rglob("*.test.tsx")))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _collect_dep_versions(
|
|
36
|
+
root: Path,
|
|
37
|
+
app_names: list[str],
|
|
38
|
+
package_names: list[str],
|
|
39
|
+
) -> dict[str, dict[str, str]]:
|
|
40
|
+
"""Collect dependency versions from apps and packages.
|
|
41
|
+
|
|
42
|
+
Returns: {dep_name: {source_name: version, ...}, ...}
|
|
43
|
+
"""
|
|
44
|
+
versions: dict[str, dict[str, str]] = {}
|
|
45
|
+
|
|
46
|
+
sources: list[tuple[str, Path]] = []
|
|
47
|
+
for name in app_names:
|
|
48
|
+
sources.append((name, get_app_dir(root, name) / "package.json"))
|
|
49
|
+
for name in package_names:
|
|
50
|
+
sources.append((name, root / "packages" / name / "package.json"))
|
|
51
|
+
|
|
52
|
+
for source_name, pkg_file in sources:
|
|
53
|
+
if not pkg_file.exists():
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
pkg = json.loads(pkg_file.read_text())
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
all_deps: dict[str, str] = {}
|
|
60
|
+
all_deps.update(pkg.get("dependencies", {}))
|
|
61
|
+
all_deps.update(pkg.get("devDependencies", {}))
|
|
62
|
+
for dep, ver in all_deps.items():
|
|
63
|
+
if dep not in versions:
|
|
64
|
+
versions[dep] = {}
|
|
65
|
+
versions[dep][source_name] = ver
|
|
66
|
+
|
|
67
|
+
return versions
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("health-report")
|
|
71
|
+
def health_report(ctx: typer.Context) -> None:
|
|
72
|
+
"""Show per-app health: test file count, codegen status, and dist presence."""
|
|
73
|
+
actx: AppContext = ctx.obj
|
|
74
|
+
out = actx.output
|
|
75
|
+
root = actx.project_root
|
|
76
|
+
|
|
77
|
+
rows: list[list[str]] = []
|
|
78
|
+
json_data: list[dict] = []
|
|
79
|
+
|
|
80
|
+
for name in actx.app_names:
|
|
81
|
+
app_dir = get_app_dir(root, name)
|
|
82
|
+
tests = _count_test_files(app_dir)
|
|
83
|
+
codegen = (app_dir / "src" / "generated").is_dir()
|
|
84
|
+
built = (app_dir / "dist").is_dir()
|
|
85
|
+
has_env = (app_dir / ".env").exists()
|
|
86
|
+
|
|
87
|
+
codegen_str = "[green]yes[/green]" if codegen else "[red]no[/red]"
|
|
88
|
+
built_str = "[green]yes[/green]" if built else "[dim]no[/dim]"
|
|
89
|
+
env_str = "[green]yes[/green]" if has_env else "[dim]no[/dim]"
|
|
90
|
+
|
|
91
|
+
rows.append([name, str(tests), codegen_str, built_str, env_str])
|
|
92
|
+
json_data.append(
|
|
93
|
+
{
|
|
94
|
+
"app": name,
|
|
95
|
+
"tests": tests,
|
|
96
|
+
"codegen": codegen,
|
|
97
|
+
"built": built,
|
|
98
|
+
"env": has_env,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
out.table(
|
|
103
|
+
"Monorepo Health Report",
|
|
104
|
+
[
|
|
105
|
+
("App", "cyan"),
|
|
106
|
+
("Tests", "green"),
|
|
107
|
+
("Codegen", ""),
|
|
108
|
+
("Built", ""),
|
|
109
|
+
(".env", ""),
|
|
110
|
+
],
|
|
111
|
+
rows,
|
|
112
|
+
data_for_json=json_data,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("cleanup")
|
|
117
|
+
def cleanup(
|
|
118
|
+
ctx: typer.Context,
|
|
119
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be removed without deleting")] = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Remove build artifacts (dist/, .turbo/, coverage/, node_modules/.cache)."""
|
|
122
|
+
actx: AppContext = ctx.obj
|
|
123
|
+
out = actx.output
|
|
124
|
+
root = actx.project_root
|
|
125
|
+
|
|
126
|
+
found: list[dict] = []
|
|
127
|
+
|
|
128
|
+
# Check apps
|
|
129
|
+
for name in actx.app_names:
|
|
130
|
+
app_dir = get_app_dir(root, name)
|
|
131
|
+
for target in _CLEANUP_TARGETS:
|
|
132
|
+
path = app_dir / target
|
|
133
|
+
if path.exists():
|
|
134
|
+
found.append({"path": str(path.relative_to(root)), "app": name, "target": str(target)})
|
|
135
|
+
|
|
136
|
+
# Check root
|
|
137
|
+
for target in _CLEANUP_TARGETS:
|
|
138
|
+
path = root / target
|
|
139
|
+
if path.exists():
|
|
140
|
+
found.append({"path": str(path.relative_to(root)), "app": "root", "target": str(target)})
|
|
141
|
+
|
|
142
|
+
if not found:
|
|
143
|
+
out.success("Nothing to clean — workspace is already tidy")
|
|
144
|
+
if out.json_mode:
|
|
145
|
+
out.raw_json([])
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if out.json_mode:
|
|
149
|
+
out.raw_json(found)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
label = "[dim](dry-run)[/dim]" if dry_run else ""
|
|
153
|
+
out.info(f"Cleaning build artifacts {label}...")
|
|
154
|
+
|
|
155
|
+
for item in found:
|
|
156
|
+
path = root / item["path"]
|
|
157
|
+
if dry_run:
|
|
158
|
+
out.info(f" Would remove: {item['path']}")
|
|
159
|
+
else:
|
|
160
|
+
try:
|
|
161
|
+
if path.is_dir():
|
|
162
|
+
shutil.rmtree(path)
|
|
163
|
+
else:
|
|
164
|
+
path.unlink()
|
|
165
|
+
out.info(f" Removed: {item['path']}")
|
|
166
|
+
except Exception as e:
|
|
167
|
+
out.warn(f" Could not remove {item['path']}: {e}")
|
|
168
|
+
|
|
169
|
+
if dry_run:
|
|
170
|
+
out.info(f"Dry-run complete — {len(found)} item(s) would be removed")
|
|
171
|
+
else:
|
|
172
|
+
out.success(f"Cleaned {len(found)} artifact(s)")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command("dr-status")
|
|
176
|
+
def dr_status(ctx: typer.Context) -> None:
|
|
177
|
+
"""Check deployment readiness: Dockerfile, docker-compose, and .env files."""
|
|
178
|
+
actx: AppContext = ctx.obj
|
|
179
|
+
out = actx.output
|
|
180
|
+
root = actx.project_root
|
|
181
|
+
|
|
182
|
+
rows: list[list[str]] = []
|
|
183
|
+
json_data: list[dict] = []
|
|
184
|
+
|
|
185
|
+
for name in actx.app_names:
|
|
186
|
+
app_dir = get_app_dir(root, name)
|
|
187
|
+
|
|
188
|
+
dockerfile = (app_dir / "Dockerfile").exists()
|
|
189
|
+
compose = (app_dir / "docker-compose.yml").exists() or (app_dir / "docker-compose.yaml").exists()
|
|
190
|
+
env_file = (app_dir / ".env").exists()
|
|
191
|
+
env_example = (app_dir / ".env.example").exists()
|
|
192
|
+
|
|
193
|
+
# Ready when at least Dockerfile is present
|
|
194
|
+
is_ready = dockerfile
|
|
195
|
+
status = "[green]ready[/green]" if is_ready else "[yellow]not ready[/yellow]"
|
|
196
|
+
|
|
197
|
+
def _icon(v: bool) -> str:
|
|
198
|
+
return "[green]yes[/green]" if v else "[dim]no[/dim]"
|
|
199
|
+
|
|
200
|
+
rows.append([name, _icon(dockerfile), _icon(compose), _icon(env_file), _icon(env_example), status])
|
|
201
|
+
json_data.append(
|
|
202
|
+
{
|
|
203
|
+
"app": name,
|
|
204
|
+
"build": dockerfile,
|
|
205
|
+
"docker": compose,
|
|
206
|
+
"env": env_file,
|
|
207
|
+
"env_example": env_example,
|
|
208
|
+
"ready": is_ready,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
out.table(
|
|
213
|
+
"Deployment Readiness Status",
|
|
214
|
+
[
|
|
215
|
+
("App", "cyan"),
|
|
216
|
+
("Dockerfile", ""),
|
|
217
|
+
("Compose", ""),
|
|
218
|
+
(".env", ""),
|
|
219
|
+
(".env.example", ""),
|
|
220
|
+
("Status", ""),
|
|
221
|
+
],
|
|
222
|
+
rows,
|
|
223
|
+
data_for_json=json_data,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command("count-test-files")
|
|
228
|
+
def count_test_files_cmd(ctx: typer.Context) -> None:
|
|
229
|
+
"""Count test files (.test.ts / .test.tsx) per app."""
|
|
230
|
+
actx: AppContext = ctx.obj
|
|
231
|
+
out = actx.output
|
|
232
|
+
root = actx.project_root
|
|
233
|
+
|
|
234
|
+
rows: list[list[str]] = []
|
|
235
|
+
json_data: list[dict] = []
|
|
236
|
+
total = 0
|
|
237
|
+
|
|
238
|
+
for name in actx.app_names:
|
|
239
|
+
app_dir = get_app_dir(root, name)
|
|
240
|
+
count = _count_test_files(app_dir)
|
|
241
|
+
total += count
|
|
242
|
+
rows.append([name, str(count)])
|
|
243
|
+
json_data.append({"app": name, "test_files": count})
|
|
244
|
+
|
|
245
|
+
rows.append(["[bold]TOTAL[/bold]", f"[bold]{total}[/bold]"])
|
|
246
|
+
|
|
247
|
+
out.table(
|
|
248
|
+
"Test File Count",
|
|
249
|
+
[("App", "cyan"), ("Test Files", "green")],
|
|
250
|
+
rows,
|
|
251
|
+
data_for_json=json_data,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command("deps-sync")
|
|
256
|
+
def deps_sync(
|
|
257
|
+
ctx: typer.Context,
|
|
258
|
+
exclude: Annotated[str | None, typer.Option("--exclude", help="Comma-separated dep names to ignore")] = None,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Check dependency version consistency across all apps and packages."""
|
|
261
|
+
actx: AppContext = ctx.obj
|
|
262
|
+
out = actx.output
|
|
263
|
+
root = actx.project_root
|
|
264
|
+
|
|
265
|
+
excluded = {e.strip() for e in exclude.split(",")} if exclude else set()
|
|
266
|
+
|
|
267
|
+
# Collect all package names from packages/
|
|
268
|
+
package_names: list[str] = []
|
|
269
|
+
packages_dir = root / "packages"
|
|
270
|
+
if packages_dir.is_dir():
|
|
271
|
+
for p in sorted(packages_dir.iterdir()):
|
|
272
|
+
if (p / "package.json").exists():
|
|
273
|
+
package_names.append(p.name)
|
|
274
|
+
|
|
275
|
+
all_versions = _collect_dep_versions(root, actx.app_names, package_names)
|
|
276
|
+
|
|
277
|
+
# Find inconsistencies: same dep, multiple different versions
|
|
278
|
+
inconsistent: list[dict] = []
|
|
279
|
+
for dep, sources in sorted(all_versions.items()):
|
|
280
|
+
if dep in excluded:
|
|
281
|
+
continue
|
|
282
|
+
unique_versions = set(sources.values())
|
|
283
|
+
if len(unique_versions) > 1:
|
|
284
|
+
inconsistent.append({"package": dep, "versions": sources})
|
|
285
|
+
|
|
286
|
+
if not inconsistent:
|
|
287
|
+
out.success("All shared dependencies are in sync")
|
|
288
|
+
if out.json_mode:
|
|
289
|
+
out.raw_json([])
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if out.json_mode:
|
|
293
|
+
out.raw_json(inconsistent)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
out.warn(f"Found {len(inconsistent)} inconsistent dependency(s):")
|
|
297
|
+
rows: list[list[str]] = []
|
|
298
|
+
for item in inconsistent:
|
|
299
|
+
for source, ver in item["versions"].items():
|
|
300
|
+
rows.append([item["package"], source, ver])
|
|
301
|
+
|
|
302
|
+
out.table(
|
|
303
|
+
"Inconsistent Dependencies",
|
|
304
|
+
[("Package", "cyan"), ("App/Package", "dim"), ("Version", "yellow")],
|
|
305
|
+
rows,
|
|
306
|
+
data_for_json=inconsistent,
|
|
307
|
+
)
|
|
308
|
+
out.warn("Run `pnpm update <package>` to align versions")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Production monitoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from kctl_lib.monitor_base import health_check_url, ssl_check
|
|
9
|
+
|
|
10
|
+
from kctl_react.core.callbacks import AppContext
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Production monitoring.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def health(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
app_name: Annotated[str, typer.Argument(help="App name")],
|
|
19
|
+
url: Annotated[str | None, typer.Option("--url")] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""HTTP health check against deployed app."""
|
|
22
|
+
actx: AppContext = ctx.obj
|
|
23
|
+
out = actx.output
|
|
24
|
+
actx.validate_app(app_name)
|
|
25
|
+
target = url or f"https://{app_name}.kodeme.io"
|
|
26
|
+
result = health_check_url(target)
|
|
27
|
+
if out.json_mode:
|
|
28
|
+
out.raw_json(result)
|
|
29
|
+
return
|
|
30
|
+
if result["healthy"]:
|
|
31
|
+
out.success(f"{target} — {result['status_code']} ({result['latency_ms']}ms)")
|
|
32
|
+
else:
|
|
33
|
+
out.error(f"{target} — {result.get('error', result.get('status_code', 'unknown'))}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("ssl")
|
|
37
|
+
def ssl_cmd(ctx: typer.Context, domain: Annotated[str, typer.Argument(help="Domain to check")]) -> None:
|
|
38
|
+
"""Check SSL certificate."""
|
|
39
|
+
actx: AppContext = ctx.obj
|
|
40
|
+
out = actx.output
|
|
41
|
+
result = ssl_check(domain)
|
|
42
|
+
if out.json_mode:
|
|
43
|
+
out.raw_json(result)
|
|
44
|
+
return
|
|
45
|
+
if result.get("valid"):
|
|
46
|
+
out.success(
|
|
47
|
+
f"{domain} — SSL valid, expires in {result.get('days_remaining', '?')} days ({result.get('issuer', '')})"
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
out.error(f"{domain} — SSL check failed: {result.get('error', 'unknown')}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Error tracking and observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_react.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Error tracking and observability.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def sentry(ctx: typer.Context, app_name: Annotated[str | None, typer.Argument(help="App name")] = None) -> None:
|
|
16
|
+
"""Show recent Sentry issues."""
|
|
17
|
+
actx: AppContext = ctx.obj
|
|
18
|
+
out = actx.output
|
|
19
|
+
out.info("Sentry integration — requires SENTRY_AUTH_TOKEN and SENTRY_ORG in env")
|
|
20
|
+
out.info("Configure via: kctl-react config set sentry_org kodemeio")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def errors(ctx: typer.Context, app_name: Annotated[str | None, typer.Argument(help="App name")] = None) -> None:
|
|
25
|
+
"""Show error trends."""
|
|
26
|
+
ctx.invoke(sentry, app_name=app_name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def uptime(ctx: typer.Context, app_name: Annotated[str | None, typer.Argument(help="App name")] = None) -> None:
|
|
31
|
+
"""Show Gatus uptime status."""
|
|
32
|
+
actx: AppContext = ctx.obj
|
|
33
|
+
out = actx.output
|
|
34
|
+
out.info("Gatus integration — check https://status.kodeme.io")
|