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,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)
|