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.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,587 @@
1
+ """Compliance audit, fix, prompt, api-check, and api-health commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from kctl_react.core.callbacks import AppContext
12
+ from kctl_react.core.discovery import get_app_dir
13
+ from kctl_react.core.compliance.engine import ComplianceEngine
14
+ from kctl_react.core.compliance.fixes import apply_fixes
15
+ from kctl_react.core.compliance.models import AppReport, CategoryResult
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ app = typer.Typer(help="Audit app compliance, auto-fix violations, and generate fix prompts.")
20
+
21
+
22
+ def _parse_categories(categories: str | None) -> list[str] | None:
23
+ """Parse comma-separated category string into list, or None."""
24
+ if not categories:
25
+ return None
26
+ return [c.strip() for c in categories.split(",") if c.strip()]
27
+
28
+
29
+ def _grade_color(grade: str) -> str:
30
+ """Return Rich color tag for a grade."""
31
+ if grade in ("A+", "A"):
32
+ return "green"
33
+ if grade == "B":
34
+ return "yellow"
35
+ return "red"
36
+
37
+
38
+ def _audit_single(actx: AppContext, report: AppReport) -> None:
39
+ """Print detailed single-app audit report."""
40
+ out = actx.output
41
+
42
+ if actx.json_mode:
43
+ out.raw_json(report.to_dict())
44
+ return
45
+
46
+ color = _grade_color(report.grade)
47
+ pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
48
+
49
+ out.text("")
50
+ out.text(f" Compliance Report: {report.app} ([{color}]{report.grade}[/{color}])")
51
+ out.text(f" Score: {pct}% ({report.total_score}/{report.max_score})")
52
+ out.text("")
53
+
54
+ # Category summary
55
+ for cat in report.categories:
56
+ if cat.violations:
57
+ vcount = len(cat.violations)
58
+ out.text(
59
+ f" [yellow]![/yellow] {cat.label:<20s} {cat.score}/{cat.max_points} ({vcount} violation{'s' if vcount != 1 else ''})"
60
+ )
61
+ else:
62
+ out.text(f" [green]\u2713[/green] {cat.label:<20s} {cat.score}/{cat.max_points}")
63
+
64
+ # Violations detail
65
+ if report.total_violations > 0:
66
+ out.text("")
67
+ out.text(f" Violations ({report.total_violations}):")
68
+
69
+ # Group by category
70
+ for cat in report.categories:
71
+ if not cat.violations:
72
+ continue
73
+ out.text("")
74
+ out.text(f" {cat.label} ({len(cat.violations)}):")
75
+ for v in cat.violations:
76
+ loc = f":{v.line}" if v.line else ""
77
+ out.text(f" [yellow]![/yellow] {v.file}{loc} {v.message}")
78
+
79
+ out.text("")
80
+ out.text(f" Auto-fixable: {report.auto_fixable_count} Needs review: {report.needs_review_count}")
81
+ out.text("")
82
+ out.text(f" Run: kctl-react compliance fix {report.app} --dry-run")
83
+ out.text(f" Run: kctl-react compliance prompt {report.app} -o fix-{report.app}.md")
84
+ else:
85
+ out.text("")
86
+ out.success("No violations found!")
87
+
88
+
89
+ def _audit_all(actx: AppContext, reports: list[AppReport]) -> None:
90
+ """Print scorecard table for all apps."""
91
+ out = actx.output
92
+
93
+ if actx.json_mode:
94
+ out.raw_json([r.to_dict() for r in reports])
95
+ return
96
+
97
+ # Build table
98
+ # Collect all category names from the first report (all should be the same)
99
+ cat_names: list[str] = []
100
+ if reports:
101
+ cat_names = [c.name for c in reports[0].categories]
102
+
103
+ columns: list[tuple[str, str]] = [
104
+ ("App", "cyan"),
105
+ ("Score", "green"),
106
+ ("Grade", "yellow"),
107
+ ]
108
+ for cn in cat_names:
109
+ columns.append((cn[:6], "dim"))
110
+
111
+ rows: list[list[str]] = []
112
+ json_data: list[dict] = []
113
+ for r in reports:
114
+ color = _grade_color(r.grade)
115
+ pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
116
+ row: list[str] = [
117
+ r.app,
118
+ f"{pct}%",
119
+ f"[{color}]{r.grade}[/{color}]",
120
+ ]
121
+ for cat in r.categories:
122
+ row.append(f"{cat.score}/{cat.max_points}")
123
+ rows.append(row)
124
+ json_data.append(r.to_dict())
125
+
126
+ out.table("Compliance Scorecard", columns, rows, data_for_json=json_data)
127
+
128
+
129
+ @app.command()
130
+ def audit(
131
+ ctx: typer.Context,
132
+ app_name: Annotated[str | None, typer.Argument(help="App name to audit")] = None,
133
+ all_apps: Annotated[bool, typer.Option("--all", help="Audit all apps")] = False,
134
+ min_score: Annotated[
135
+ int | None, typer.Option("--min-score", help="Minimum passing score (percentage 0-100)")
136
+ ] = None,
137
+ categories: Annotated[
138
+ str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
139
+ ] = None,
140
+ ) -> None:
141
+ """Run compliance audit on one or all apps.
142
+
143
+ Examples:
144
+ kctl-react compliance audit sfa
145
+ kctl-react compliance audit --all
146
+ kctl-react compliance audit sfa --min-score 80
147
+ kctl-react compliance audit sfa --categories structure,imports
148
+ kctl-react --json compliance audit --all
149
+ """
150
+ actx: AppContext = ctx.obj
151
+ out = actx.output
152
+ root = actx.project_root
153
+
154
+ if not app_name and not all_apps:
155
+ out.error("Specify an app name or use --all")
156
+ raise typer.Exit(1) from None
157
+
158
+ cat_list = _parse_categories(categories)
159
+ engine = ComplianceEngine()
160
+
161
+ if all_apps:
162
+ reports: list[AppReport] = []
163
+ for name in actx.app_names:
164
+ app_path = get_app_dir(root, name)
165
+ report = engine.audit(app_path, name, categories=cat_list)
166
+ reports.append(report)
167
+
168
+ _audit_all(actx, reports)
169
+
170
+ if min_score is not None:
171
+ any_below = False
172
+ for r in reports:
173
+ pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
174
+ if pct < min_score:
175
+ out.error(f"{r.app} scored {pct}% (below {min_score}%)")
176
+ any_below = True
177
+ if any_below:
178
+ raise typer.Exit(1) from None
179
+ else:
180
+ assert app_name is not None
181
+ actx.validate_app(app_name)
182
+ app_path = get_app_dir(root, app_name)
183
+ report = engine.audit(app_path, app_name, categories=cat_list)
184
+ _audit_single(actx, report)
185
+
186
+ if min_score is not None:
187
+ pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
188
+ if pct < min_score:
189
+ out.error(f"{report.app} scored {pct}% (below {min_score}%)")
190
+ raise typer.Exit(1) from None
191
+
192
+
193
+ @app.command()
194
+ def fix(
195
+ ctx: typer.Context,
196
+ app_name: Annotated[str | None, typer.Argument(help="App name to fix")] = None,
197
+ all_apps: Annotated[bool, typer.Option("--all", help="Fix all apps")] = False,
198
+ dry_run: Annotated[
199
+ bool, typer.Option("--dry-run", help="Show what would be fixed without modifying files")
200
+ ] = False,
201
+ categories: Annotated[
202
+ str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
203
+ ] = None,
204
+ ) -> None:
205
+ """Apply auto-fixes for compliance violations.
206
+
207
+ Examples:
208
+ kctl-react compliance fix sfa
209
+ kctl-react compliance fix sfa --dry-run
210
+ kctl-react compliance fix --all
211
+ kctl-react compliance fix sfa --categories structure,i18n
212
+ """
213
+ actx: AppContext = ctx.obj
214
+ out = actx.output
215
+ root = actx.project_root
216
+
217
+ if not app_name and not all_apps:
218
+ out.error("Specify an app name or use --all")
219
+ raise typer.Exit(1) from None
220
+
221
+ cat_list = _parse_categories(categories)
222
+ engine = ComplianceEngine()
223
+
224
+ names = actx.app_names if all_apps else [app_name] # type: ignore[list-item]
225
+ for name in names:
226
+ if not all_apps:
227
+ actx.validate_app(name)
228
+ app_path = get_app_dir(root, name)
229
+
230
+ prefix = "(dry-run) " if dry_run else ""
231
+ count = apply_fixes(app_path, name, dry_run=dry_run, categories=cat_list)
232
+ out.info(f"{prefix}{name}: {count} fix{'es' if count != 1 else ''} applied")
233
+
234
+ # Re-audit to show remaining issues
235
+ report = engine.audit(app_path, name, categories=cat_list)
236
+ remaining = report.needs_review_count
237
+ if remaining > 0:
238
+ out.text(f" Remaining issues needing review: {remaining}")
239
+ else:
240
+ out.success(f" {name}: all clear!")
241
+
242
+
243
+ @app.command()
244
+ def prompt(
245
+ ctx: typer.Context,
246
+ app_name: Annotated[str | None, typer.Argument(help="App name to generate prompt for")] = None,
247
+ all_apps: Annotated[bool, typer.Option("--all", help="Generate prompts for all apps")] = False,
248
+ output_file: Annotated[
249
+ str | None, typer.Option("-o", "--output", help="Output file path (directory if --all)")
250
+ ] = None,
251
+ categories: Annotated[
252
+ str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
253
+ ] = None,
254
+ ) -> None:
255
+ """Generate markdown fix prompts for AI or human review.
256
+
257
+ Examples:
258
+ kctl-react compliance prompt sfa
259
+ kctl-react compliance prompt sfa -o fix-sfa.md
260
+ kctl-react compliance prompt --all -o fix-prompts/
261
+ """
262
+ actx: AppContext = ctx.obj
263
+ out = actx.output
264
+ root = actx.project_root
265
+
266
+ if not app_name and not all_apps:
267
+ out.error("Specify an app name or use --all")
268
+ raise typer.Exit(1) from None
269
+
270
+ cat_list = _parse_categories(categories)
271
+ engine = ComplianceEngine()
272
+
273
+ if all_apps:
274
+ for name in actx.app_names:
275
+ app_path = get_app_dir(root, name)
276
+ report = engine.audit(app_path, name, categories=cat_list)
277
+ md = engine.generate_prompt(report)
278
+
279
+ if output_file:
280
+ out_dir = Path(output_file)
281
+ out_dir.mkdir(parents=True, exist_ok=True)
282
+ dest = out_dir / f"fix-{name}.md"
283
+ dest.write_text(md)
284
+ out.info(f"Saved: {dest}")
285
+ else:
286
+ out.text(md)
287
+ else:
288
+ assert app_name is not None
289
+ actx.validate_app(app_name)
290
+ app_path = get_app_dir(root, app_name)
291
+ report = engine.audit(app_path, app_name, categories=cat_list)
292
+ md = engine.generate_prompt(report)
293
+
294
+ if output_file:
295
+ dest = Path(output_file)
296
+ dest.parent.mkdir(parents=True, exist_ok=True)
297
+ dest.write_text(md)
298
+ out.info(f"Saved: {dest}")
299
+ else:
300
+ out.text(md)
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Helpers for api-check / api-health
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def _read_env_var(app_path: Path, var_name: str) -> str | None:
309
+ """Read a single variable from the app's .env file."""
310
+ env_file = app_path / ".env"
311
+ if not env_file.is_file():
312
+ return None
313
+ for line in env_file.read_text().splitlines():
314
+ if line.startswith(f"{var_name}="):
315
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
316
+ return None
317
+
318
+
319
+ def _run_api_check_for_app(
320
+ app_path: Path,
321
+ app_name: str,
322
+ *,
323
+ offline: bool,
324
+ cat_list: list[str] | None,
325
+ ) -> AppReport | None:
326
+ """Run api-check checkers for a single app, returning AppReport or None if no schema."""
327
+ from kctl_react.core.compliance.api_check.checks import ALL_API_CHECKERS, CHECKER_MAP
328
+ from kctl_react.core.compliance.api_check.hooks import parse_hooks
329
+ from kctl_react.core.compliance.api_check.matcher import match_hooks_to_schema
330
+ from kctl_react.core.compliance.api_check.schema import load_schema, parse_schema
331
+
332
+ schema_raw = load_schema(app_path, app_name, offline=offline)
333
+ if schema_raw is None:
334
+ return None
335
+
336
+ schema = parse_schema(schema_raw)
337
+ hooks = parse_hooks(app_path)
338
+ match = match_hooks_to_schema(hooks, schema)
339
+
340
+ # Select checkers (filtered by --categories if given)
341
+ if cat_list:
342
+ checkers = [CHECKER_MAP[c] for c in cat_list if c in CHECKER_MAP]
343
+ else:
344
+ checkers = list(ALL_API_CHECKERS)
345
+
346
+ categories: list[CategoryResult] = []
347
+ for checker in checkers:
348
+ result = checker.check(schema, hooks, match)
349
+ categories.append(result)
350
+
351
+ return AppReport(app=app_name, categories=categories)
352
+
353
+
354
+ def _run_api_health_for_app(
355
+ app_path: Path,
356
+ app_name: str,
357
+ *,
358
+ url: str | None,
359
+ timeout: float,
360
+ token: str | None,
361
+ cat_list: list[str] | None,
362
+ ) -> AppReport | None:
363
+ """Run api-health checkers for a single app, returning AppReport or None if no schema."""
364
+ from kctl_react.core.compliance.api_check.schema import (
365
+ APP_BACKEND_MAP,
366
+ load_schema,
367
+ parse_schema,
368
+ )
369
+ from kctl_react.core.compliance.api_health import run_health_checks
370
+ from kctl_react.core.compliance.api_health.checks import AuthChecker
371
+ from kctl_react.core.compliance.api_health.checks import ReachableChecker, ResponseChecker, TimingChecker
372
+ from kctl_react.core.compliance.api_health.client import HealthClient
373
+ from kctl_react.core.compliance.api_health.sampler import sample_endpoints
374
+
375
+ schema_raw = load_schema(app_path, app_name, offline=False)
376
+ if schema_raw is None:
377
+ return None
378
+
379
+ schema = parse_schema(schema_raw)
380
+
381
+ # Resolve base URL
382
+ base_url = url
383
+ if not base_url:
384
+ base_url = _read_env_var(app_path, "VITE_API_BASE_URL")
385
+ if not base_url:
386
+ base_url = _read_env_var(app_path, "NEXT_PUBLIC_API_BASE_URL")
387
+ if not base_url:
388
+ backend = APP_BACKEND_MAP.get(app_name, app_name)
389
+ base_url = f"http://localhost:8069/{backend}/api"
390
+
391
+ client = HealthClient(base_url=base_url, token=token, timeout=timeout)
392
+
393
+ # Authenticate if no token provided
394
+ if not client.token:
395
+ if not client.authenticate(app_path, app_name):
396
+ pass
397
+
398
+ endpoints = sample_endpoints(schema)
399
+ results = run_health_checks(client, endpoints)
400
+
401
+ # Run checkers (filtered by --categories if given)
402
+ categories: list[CategoryResult] = []
403
+
404
+ checker_instances = {
405
+ "auth": lambda: AuthChecker().check(client, schema),
406
+ "reachable": lambda: ReachableChecker().check(results),
407
+ "response": lambda: ResponseChecker().check(results),
408
+ "timing": lambda: TimingChecker().check(results, threshold=timeout),
409
+ }
410
+
411
+ if cat_list:
412
+ selected = [c for c in cat_list if c in checker_instances]
413
+ else:
414
+ selected = list(checker_instances.keys())
415
+
416
+ for name in selected:
417
+ categories.append(checker_instances[name]())
418
+
419
+ return AppReport(app=app_name, categories=categories)
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # api-check command
424
+ # ---------------------------------------------------------------------------
425
+
426
+
427
+ @app.command("api-check")
428
+ def api_check_cmd(
429
+ ctx: typer.Context,
430
+ app_name: Annotated[str | None, typer.Argument(help="App name to check")] = None,
431
+ all_apps: Annotated[bool, typer.Option("--all", help="Check all apps")] = False,
432
+ offline: Annotated[bool, typer.Option("--offline", help="Skip schema fetching, use cached only")] = False,
433
+ categories: Annotated[
434
+ str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
435
+ ] = None,
436
+ min_score: Annotated[
437
+ int | None, typer.Option("--min-score", help="Minimum passing score (percentage 0-100)")
438
+ ] = None,
439
+ ) -> None:
440
+ """Check frontend API hooks against OpenAPI schema.
441
+
442
+ Examples:
443
+ kctl-react compliance api-check sfa
444
+ kctl-react compliance api-check --all
445
+ kctl-react compliance api-check sfa --offline
446
+ kctl-react compliance api-check sfa --categories endpoints,types
447
+ kctl-react compliance api-check --all --min-score 80
448
+ """
449
+ actx: AppContext = ctx.obj
450
+ out = actx.output
451
+ root = actx.project_root
452
+
453
+ if not app_name and not all_apps:
454
+ out.error("Specify an app name or use --all")
455
+ raise typer.Exit(1) from None
456
+
457
+ cat_list = _parse_categories(categories)
458
+
459
+ if all_apps:
460
+ reports: list[AppReport] = []
461
+ for name in actx.app_names:
462
+ app_path = get_app_dir(root, name)
463
+ report = _run_api_check_for_app(app_path, name, offline=offline, cat_list=cat_list)
464
+ if report is None:
465
+ out.warn(f"No OpenAPI schema available for {name}")
466
+ continue
467
+ reports.append(report)
468
+
469
+ _audit_all(actx, reports)
470
+
471
+ if min_score is not None:
472
+ any_below = False
473
+ for r in reports:
474
+ pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
475
+ if pct < min_score:
476
+ out.error(f"{r.app} scored {pct}% (below {min_score}%)")
477
+ any_below = True
478
+ if any_below:
479
+ raise typer.Exit(1) from None
480
+ else:
481
+ assert app_name is not None
482
+ actx.validate_app(app_name)
483
+ app_path = get_app_dir(root, app_name)
484
+ report = _run_api_check_for_app(app_path, app_name, offline=offline, cat_list=cat_list)
485
+ if report is None:
486
+ out.warn(f"No OpenAPI schema available for {app_name}")
487
+ raise typer.Exit(0) from None
488
+
489
+ _audit_single(actx, report)
490
+
491
+ if min_score is not None:
492
+ pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
493
+ if pct < min_score:
494
+ out.error(f"{report.app} scored {pct}% (below {min_score}%)")
495
+ raise typer.Exit(1) from None
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # api-health command
500
+ # ---------------------------------------------------------------------------
501
+
502
+
503
+ @app.command("api-health")
504
+ def api_health_cmd(
505
+ ctx: typer.Context,
506
+ app_name: Annotated[str | None, typer.Argument(help="App name to health-check")] = None,
507
+ all_apps: Annotated[bool, typer.Option("--all", help="Health-check all apps")] = False,
508
+ url: Annotated[str | None, typer.Option("--url", help="Override base URL for backend")] = None,
509
+ timeout: Annotated[float, typer.Option("--timeout", help="Per-request timeout in seconds")] = 3.0,
510
+ token: Annotated[str | None, typer.Option("--token", help="Pre-authenticated JWT token")] = None,
511
+ categories: Annotated[
512
+ str | None, typer.Option("--categories", "-c", help="Comma-separated category filter")
513
+ ] = None,
514
+ min_score: Annotated[
515
+ int | None, typer.Option("--min-score", help="Minimum passing score (percentage 0-100)")
516
+ ] = None,
517
+ ) -> None:
518
+ """Run live health checks against backend API endpoints.
519
+
520
+ Examples:
521
+ kctl-react compliance api-health sfa
522
+ kctl-react compliance api-health sfa --url https://api.kodeme.io
523
+ kctl-react compliance api-health --all --url http://localhost:8069
524
+ kctl-react compliance api-health sfa --timeout 5
525
+ kctl-react compliance api-health sfa --token eyJ...
526
+ """
527
+ actx: AppContext = ctx.obj
528
+ out = actx.output
529
+ root = actx.project_root
530
+
531
+ if not app_name and not all_apps:
532
+ out.error("Specify an app name or use --all")
533
+ raise typer.Exit(1) from None
534
+
535
+ cat_list = _parse_categories(categories)
536
+
537
+ if all_apps:
538
+ reports: list[AppReport] = []
539
+ for name in actx.app_names:
540
+ app_path = get_app_dir(root, name)
541
+ report = _run_api_health_for_app(
542
+ app_path,
543
+ name,
544
+ url=url,
545
+ timeout=timeout,
546
+ token=token,
547
+ cat_list=cat_list,
548
+ )
549
+ if report is None:
550
+ out.warn(f"No OpenAPI schema available for {name}")
551
+ continue
552
+ reports.append(report)
553
+
554
+ _audit_all(actx, reports)
555
+
556
+ if min_score is not None:
557
+ any_below = False
558
+ for r in reports:
559
+ pct = int(r.total_score / r.max_score * 100) if r.max_score > 0 else 100
560
+ if pct < min_score:
561
+ out.error(f"{r.app} scored {pct}% (below {min_score}%)")
562
+ any_below = True
563
+ if any_below:
564
+ raise typer.Exit(1) from None
565
+ else:
566
+ assert app_name is not None
567
+ actx.validate_app(app_name)
568
+ app_path = get_app_dir(root, app_name)
569
+ report = _run_api_health_for_app(
570
+ app_path,
571
+ app_name,
572
+ url=url,
573
+ timeout=timeout,
574
+ token=token,
575
+ cat_list=cat_list,
576
+ )
577
+ if report is None:
578
+ out.warn(f"No OpenAPI schema available for {app_name}")
579
+ raise typer.Exit(0) from None
580
+
581
+ _audit_single(actx, report)
582
+
583
+ if min_score is not None:
584
+ pct = int(report.total_score / report.max_score * 100) if report.max_score > 0 else 100
585
+ if pct < min_score:
586
+ out.error(f"{report.app} scored {pct}% (below {min_score}%)")
587
+ raise typer.Exit(1) from None