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,289 @@
1
+ """CI/CD pipeline commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_react.core.callbacks import AppContext
11
+ from kctl_react.core.exceptions import CommandError
12
+ from kctl_react.core.runner import run
13
+
14
+ app = typer.Typer(help="CI/CD pipeline gates, affected builds, and releases.")
15
+
16
+
17
+ def _run_step(
18
+ name: str,
19
+ cmd: list[str],
20
+ cwd: object,
21
+ timeout: int = 300,
22
+ ) -> dict:
23
+ """Run a pipeline step and return result dict."""
24
+ start = time.monotonic()
25
+ try:
26
+ run(cmd, cwd=cwd, capture=True, timeout=timeout)
27
+ elapsed = round(time.monotonic() - start, 1)
28
+ return {"step": name, "status": "pass", "duration": elapsed, "exit_code": 0, "stderr": ""}
29
+ except CommandError as e:
30
+ elapsed = round(time.monotonic() - start, 1)
31
+ stderr = (e.stderr or "")[-2000:]
32
+ return {"step": name, "status": "fail", "duration": elapsed, "exit_code": e.returncode, "stderr": stderr}
33
+ except Exception as e:
34
+ elapsed = round(time.monotonic() - start, 1)
35
+ return {"step": name, "status": "fail", "duration": elapsed, "exit_code": -1, "stderr": str(e)[-2000:]}
36
+
37
+
38
+ def _run_gate(
39
+ root: object,
40
+ app_name: str | None,
41
+ strict: bool,
42
+ skip_build: bool,
43
+ ) -> list[dict]:
44
+ """Execute gate pipeline steps and return results."""
45
+ from pathlib import Path
46
+
47
+ root_path = Path(str(root))
48
+ filter_args = ["--filter", f"@kodemeio/{app_name}"] if app_name else []
49
+
50
+ steps: list[tuple[str, list[str], int]] = [
51
+ ("lint", ["pnpm", "turbo", "run", "lint", *filter_args], 300),
52
+ ("type-check", ["pnpm", "turbo", "run", "type-check", *filter_args], 300),
53
+ ("test:ci", ["pnpm", "turbo", "run", "test:ci", *filter_args], 300),
54
+ ("audit", ["pnpm", "audit"], 120),
55
+ ]
56
+
57
+ if not skip_build:
58
+ steps.append(("build", ["pnpm", "turbo", "run", "build", *filter_args], 600))
59
+
60
+ if strict:
61
+ # Also run secrets scan (reuse security helpers)
62
+ steps.append(("secrets", ["echo", "secrets-check"], 10)) # placeholder, handled below
63
+
64
+ results: list[dict] = []
65
+ for step_name, cmd, timeout in steps:
66
+ if step_name == "secrets":
67
+ # Run secrets check via internal helper
68
+ from kctl_react.commands.security import _collect_secrets
69
+
70
+ findings = _collect_secrets(root_path, app_name, [], root_path / "packages")
71
+ # If app_name not specified, scan all apps
72
+ if not app_name:
73
+ from kctl_react.core.discovery import discover_apps
74
+
75
+ all_apps = list(discover_apps(root_path).keys())
76
+ findings = _collect_secrets(root_path, None, all_apps, root_path / "packages")
77
+ result = {
78
+ "step": "secrets",
79
+ "status": "pass" if len(findings) == 0 else "fail",
80
+ "duration": 0.0,
81
+ "exit_code": 0 if len(findings) == 0 else 1,
82
+ "stderr": f"{len(findings)} secret(s) found" if findings else "",
83
+ }
84
+ results.append(result)
85
+ else:
86
+ results.append(_run_step(step_name, cmd, root_path, timeout))
87
+
88
+ return results
89
+
90
+
91
+ @app.command()
92
+ def gate(
93
+ ctx: typer.Context,
94
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
95
+ strict: Annotated[bool, typer.Option("--strict", help="Also run secrets + headers checks")] = False,
96
+ skip_build: Annotated[bool, typer.Option("--skip-build", help="Skip the build step")] = False,
97
+ ) -> None:
98
+ """Run full CI gate: lint → type-check → test → audit → build."""
99
+ actx: AppContext = ctx.obj
100
+ out = actx.output
101
+ root = actx.project_root
102
+
103
+ if app_name:
104
+ actx.validate_app(app_name)
105
+
106
+ target = app_name or "all apps"
107
+ out.info(f"Running pipeline gate for {target}...")
108
+
109
+ results = _run_gate(root, app_name, strict, skip_build)
110
+
111
+ # Display results
112
+ rows: list[list[str]] = []
113
+ for r in results:
114
+ status = "[green]PASS[/green]" if r["status"] == "pass" else "[red]FAIL[/red]"
115
+ rows.append([r["step"], status, f"{r['duration']}s", str(r["exit_code"])])
116
+
117
+ out.table(
118
+ f"Pipeline Gate — {target}",
119
+ [("Step", "cyan"), ("Status", ""), ("Duration", "green"), ("Exit", "dim")],
120
+ rows,
121
+ data_for_json=results,
122
+ )
123
+
124
+ passed = sum(1 for r in results if r["status"] == "pass")
125
+ failed = sum(1 for r in results if r["status"] == "fail")
126
+
127
+ if failed == 0:
128
+ out.success(f"All {passed} steps passed")
129
+ else:
130
+ out.error(f"{failed}/{len(results)} step(s) failed")
131
+ # Show stderr for failed steps
132
+ for r in results:
133
+ if r["status"] == "fail" and r["stderr"]:
134
+ out.text(f"\n[red]{r['step']}[/red] stderr:\n{r['stderr'][:500]}")
135
+ raise typer.Exit(1) from None
136
+
137
+
138
+ @app.command("affected-gate")
139
+ def affected_gate(
140
+ ctx: typer.Context,
141
+ base: Annotated[str, typer.Option("--base", help="Git base ref for diff")] = "main",
142
+ strict: Annotated[bool, typer.Option("--strict", help="Also run secrets checks")] = False,
143
+ ) -> None:
144
+ """Run gate only for apps affected by git changes."""
145
+ from kctl_react.core.git import get_affected_apps
146
+
147
+ actx: AppContext = ctx.obj
148
+ out = actx.output
149
+ root = actx.project_root
150
+
151
+ out.info(f"Detecting affected apps (vs {base})...")
152
+ affected = get_affected_apps(root, base)
153
+
154
+ if not affected:
155
+ out.success("No apps affected — nothing to check")
156
+ if out.json_mode:
157
+ out.raw_json({"affected": [], "results": {}})
158
+ return
159
+
160
+ out.info(f"Affected apps: {', '.join(affected)}")
161
+
162
+ all_results: dict[str, list[dict]] = {}
163
+ any_failed = False
164
+
165
+ for app_name in affected:
166
+ out.header(app_name)
167
+ results = _run_gate(root, app_name, strict, skip_build=False)
168
+ all_results[app_name] = results
169
+
170
+ for r in results:
171
+ if r["status"] == "fail":
172
+ any_failed = True
173
+
174
+ # Summary
175
+ rows: list[list[str]] = []
176
+ json_data: list[dict] = []
177
+
178
+ for app_name, results in all_results.items():
179
+ passed = sum(1 for r in results if r["status"] == "pass")
180
+ total = len(results)
181
+ status = "[green]PASS[/green]" if passed == total else "[red]FAIL[/red]"
182
+ duration = sum(r["duration"] for r in results)
183
+ rows.append([app_name, f"{passed}/{total}", f"{duration:.1f}s", status])
184
+ json_data.append({"app": app_name, "passed": passed, "total": total, "duration": duration, "steps": results})
185
+
186
+ out.table(
187
+ f"Affected Gate Summary (vs {base})",
188
+ [("App", "cyan"), ("Steps", "green"), ("Duration", ""), ("Status", "")],
189
+ rows,
190
+ data_for_json=json_data,
191
+ )
192
+
193
+ if any_failed:
194
+ out.error("Some apps have failing steps")
195
+ raise typer.Exit(1)
196
+ else:
197
+ out.success(f"All {len(affected)} affected app(s) passed")
198
+
199
+
200
+ @app.command()
201
+ def release(
202
+ ctx: typer.Context,
203
+ app_name: Annotated[str, typer.Argument(help="App name")],
204
+ tag: Annotated[str | None, typer.Option("--tag", "-t", help="Image tag (default: git short SHA)")] = None,
205
+ push: Annotated[bool, typer.Option("--push", help="Push image to GHCR")] = False,
206
+ ) -> None:
207
+ """Build Docker image for an app and optionally push to GHCR."""
208
+ from kctl_react.core.git import get_last_commit_hash
209
+
210
+ actx: AppContext = ctx.obj
211
+ out = actx.output
212
+ root = actx.project_root
213
+
214
+ actx.validate_app(app_name)
215
+
216
+ # Determine tag
217
+ image_tag = tag or get_last_commit_hash(root) or "latest"
218
+ image_name = f"ghcr.io/kodemeio/kodemeio-react/{app_name}:{image_tag}"
219
+
220
+ out.info(f"Building Docker image: {image_name}")
221
+
222
+ # Build
223
+ try:
224
+ run(
225
+ [
226
+ "docker",
227
+ "build",
228
+ "--build-arg",
229
+ f"APP_NAME={app_name}",
230
+ "-t",
231
+ image_name,
232
+ ".",
233
+ ],
234
+ cwd=root,
235
+ capture=True,
236
+ timeout=600,
237
+ )
238
+ except CommandError as e:
239
+ out.error(f"Docker build failed: {e.stderr[:500] if e.stderr else e}")
240
+ raise typer.Exit(1) from None
241
+
242
+ # Get image size
243
+ image_size = "unknown"
244
+ try:
245
+ inspect = run(
246
+ ["docker", "image", "inspect", image_name, "--format", "{{.Size}}"],
247
+ cwd=root,
248
+ capture=True,
249
+ timeout=10,
250
+ )
251
+ size_bytes = int(inspect.stdout.strip())
252
+ from kctl_react.commands.build import _format_size
253
+
254
+ image_size = _format_size(size_bytes)
255
+ except Exception:
256
+ pass
257
+
258
+ release_info = {
259
+ "app": app_name,
260
+ "image": image_name,
261
+ "tag": image_tag,
262
+ "size": image_size,
263
+ "pushed": False,
264
+ }
265
+
266
+ # Push if requested
267
+ if push:
268
+ out.info(f"Pushing {image_name}...")
269
+ try:
270
+ run(["docker", "push", image_name], cwd=root, capture=True, timeout=300)
271
+ release_info["pushed"] = True
272
+ out.success(f"Pushed {image_name}")
273
+ except CommandError as e:
274
+ out.error(f"Push failed: {e.stderr[:300] if e.stderr else e}")
275
+ raise typer.Exit(1) from None
276
+
277
+ out.table(
278
+ f"Release — {app_name}",
279
+ [("Field", "cyan"), ("Value", "")],
280
+ [
281
+ ["Image", image_name],
282
+ ["Tag", image_tag],
283
+ ["Size", image_size],
284
+ ["Pushed", "yes" if release_info["pushed"] else "no"],
285
+ ],
286
+ data_for_json=[release_info],
287
+ )
288
+
289
+ out.success(f"Release complete: {image_name}")
@@ -0,0 +1,193 @@
1
+ """PWA and Service Worker management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_react.core.callbacks import AppContext
11
+ from kctl_react.core.discovery import get_app_dir
12
+
13
+ app = typer.Typer(help="PWA and Service Worker management (Vite apps).")
14
+
15
+
16
+ def _require_vite(actx: AppContext, app_name: str) -> None:
17
+ """Exit with a warning if the app is not a Vite PWA."""
18
+ if actx.is_nextjs(app_name):
19
+ actx.output.warn(
20
+ f"{app_name} is a Next.js app. PWA commands are for Vite apps only. "
21
+ "Use next-pwa or @ducanh2912/next-pwa directly for Next.js PWA management."
22
+ )
23
+ raise typer.Exit(0)
24
+
25
+
26
+ @app.command()
27
+ def status(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
28
+ """Show PWA status — service worker version, precache count, manifest."""
29
+ actx: AppContext = ctx.obj
30
+ out = actx.output
31
+ actx.validate_app(app_name)
32
+ _require_vite(actx, app_name)
33
+ root = actx.project_root
34
+ dist = get_app_dir(root, app_name) / "dist"
35
+ sw = dist / "sw.js"
36
+ manifest = dist / "manifest.webmanifest"
37
+ data = {"app": app_name, "sw_exists": sw.exists(), "manifest_exists": manifest.exists()}
38
+ if sw.exists():
39
+ content = sw.read_text(errors="ignore")
40
+ data["sw_size"] = sw.stat().st_size
41
+ data["precache_count"] = content.count("url:")
42
+ if manifest.exists():
43
+ try:
44
+ m = json.loads(manifest.read_text())
45
+ data["name"] = m.get("name", "")
46
+ data["start_url"] = m.get("start_url", "")
47
+ data["display"] = m.get("display", "")
48
+ data["icons"] = len(m.get("icons", []))
49
+ except json.JSONDecodeError:
50
+ data["manifest_error"] = "Invalid JSON"
51
+ if out.json_mode:
52
+ out.raw_json(data)
53
+ return
54
+ sections = [("PWA Status", [(k, str(v)) for k, v in data.items()])]
55
+ out.detail(f"PWA — {app_name}", sections, data_for_json=data)
56
+
57
+
58
+ @app.command("cache-list")
59
+ def cache_list(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
60
+ """List precached URLs from service worker manifest."""
61
+ actx: AppContext = ctx.obj
62
+ out = actx.output
63
+ actx.validate_app(app_name)
64
+ _require_vite(actx, app_name)
65
+ dist = actx.get_app_dir(app_name) / "dist"
66
+ sw = dist / "sw.js"
67
+ if not sw.exists():
68
+ out.warn(f"No built service worker at {sw}")
69
+ return
70
+ content = sw.read_text(errors="ignore")
71
+ # Parse workbox precache manifest entries
72
+ import re
73
+
74
+ urls = re.findall(r'"url"\s*:\s*"([^"]+)"', content)
75
+ rows = [[url, "precache"] for url in urls]
76
+ out.table(
77
+ f"Cached URLs — {app_name}",
78
+ [("URL", "cyan"), ("Strategy", "dim")],
79
+ rows,
80
+ data_for_json=[{"url": u, "strategy": "precache"} for u in urls],
81
+ )
82
+
83
+
84
+ @app.command("cache-clear")
85
+ def cache_clear(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
86
+ """Clear built service worker files."""
87
+ actx: AppContext = ctx.obj
88
+ out = actx.output
89
+ actx.validate_app(app_name)
90
+ _require_vite(actx, app_name)
91
+ dist = actx.get_app_dir(app_name) / "dist"
92
+ cleared = 0
93
+ for f in [dist / "sw.js", dist / "sw.js.map", dist / "workbox-*.js"]:
94
+ if f.exists():
95
+ f.unlink()
96
+ cleared += 1
97
+ # Also glob workbox files
98
+ for f in dist.glob("workbox-*.js"):
99
+ f.unlink()
100
+ cleared += 1
101
+ out.success(f"Cleared {cleared} service worker file(s)")
102
+
103
+
104
+ @app.command("manifest-validate")
105
+ def manifest_validate(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
106
+ """Validate web app manifest against PWA requirements."""
107
+ actx: AppContext = ctx.obj
108
+ out = actx.output
109
+ actx.validate_app(app_name)
110
+ dist = actx.get_app_dir(app_name) / "dist"
111
+ manifest_file = dist / "manifest.webmanifest"
112
+ if not manifest_file.exists():
113
+ # Check public/ too
114
+ manifest_file = actx.get_app_dir(app_name) / "public" / "manifest.webmanifest"
115
+ if not manifest_file.exists():
116
+ out.error("No manifest.webmanifest found in dist/ or public/")
117
+ raise typer.Exit(1) from None
118
+ m = json.loads(manifest_file.read_text())
119
+ issues = []
120
+ required = ["name", "short_name", "start_url", "display", "icons"]
121
+ for key in required:
122
+ if key not in m:
123
+ issues.append(f"Missing required field: {key}")
124
+ if "icons" in m:
125
+ sizes = [icon.get("sizes", "") for icon in m["icons"]]
126
+ if "192x192" not in sizes:
127
+ issues.append("Missing 192x192 icon")
128
+ if "512x512" not in sizes:
129
+ issues.append("Missing 512x512 icon")
130
+ if m.get("display") not in ("standalone", "fullscreen", "minimal-ui"):
131
+ issues.append(f"display should be standalone/fullscreen/minimal-ui, got: {m.get('display')}")
132
+ if issues:
133
+ out.error(f"{len(issues)} issue(s) found:")
134
+ for issue in issues:
135
+ out.text(f" - {issue}")
136
+ raise typer.Exit(1)
137
+ out.success("Manifest is valid")
138
+
139
+
140
+ @app.command("offline-report")
141
+ def offline_report(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
142
+ """Analyze offline support coverage."""
143
+ actx: AppContext = ctx.obj
144
+ out = actx.output
145
+ actx.validate_app(app_name)
146
+ _require_vite(actx, app_name)
147
+ # Count total routes vs precached routes
148
+ app_dir = actx.get_app_dir(app_name) / "src" / "pages"
149
+ total_pages = len(list(app_dir.glob("**/*.tsx"))) if app_dir.exists() else 0
150
+ dist = actx.get_app_dir(app_name) / "dist"
151
+ sw = dist / "sw.js"
152
+ precached = 0
153
+ if sw.exists():
154
+ import re
155
+
156
+ precached = len(re.findall(r'"url"\s*:\s*"([^"]+)"', sw.read_text(errors="ignore")))
157
+ out.table(
158
+ "Offline Coverage",
159
+ [("Metric", "cyan"), ("Value", "")],
160
+ [["Total pages", str(total_pages)], ["Precached assets", str(precached)]],
161
+ data_for_json=[
162
+ {"metric": "total_pages", "value": total_pages},
163
+ {"metric": "precached_assets", "value": precached},
164
+ ],
165
+ )
166
+
167
+
168
+ @app.command("sw-info")
169
+ def sw_info(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
170
+ """Show vite-plugin-pwa configuration."""
171
+ actx: AppContext = ctx.obj
172
+ out = actx.output
173
+ actx.validate_app(app_name)
174
+ _require_vite(actx, app_name)
175
+ vite_config = actx.get_app_dir(app_name) / "vite.config.ts"
176
+ if not vite_config.exists():
177
+ out.error("No vite.config.ts found")
178
+ raise typer.Exit(1)
179
+ content = vite_config.read_text()
180
+ import re
181
+
182
+ register_type = re.search(r"registerType\s*:\s*['\"](\w+)['\"]", content)
183
+ strategies = re.findall(r"(CacheFirst|NetworkFirst|StaleWhileRevalidate|NetworkOnly|CacheOnly)", content)
184
+ data = {
185
+ "register_type": register_type.group(1) if register_type else "unknown",
186
+ "runtime_caching_strategies": list(set(strategies)),
187
+ "has_workbox_config": "workbox" in content.lower(),
188
+ }
189
+ if out.json_mode:
190
+ out.raw_json(data)
191
+ return
192
+ sections = [("PWA Config", [(k, str(v)) for k, v in data.items()])]
193
+ out.detail(f"Service Worker Config — {app_name}", sections)