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,792 @@
|
|
|
1
|
+
"""Dependency management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
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
|
+
from kctl_react.core.exceptions import CommandError
|
|
15
|
+
from kctl_react.core.runner import run, run_pnpm
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Dependency management and analysis.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def outdated(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
app_name: Annotated[str | None, typer.Argument(help="App name (omit for root)")] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Check for outdated dependencies."""
|
|
26
|
+
actx: AppContext = ctx.obj
|
|
27
|
+
out = actx.output
|
|
28
|
+
root = actx.project_root
|
|
29
|
+
|
|
30
|
+
if app_name:
|
|
31
|
+
actx.validate_app(app_name)
|
|
32
|
+
cwd = get_app_dir(root, app_name)
|
|
33
|
+
else:
|
|
34
|
+
cwd = root
|
|
35
|
+
|
|
36
|
+
target = app_name or "monorepo root"
|
|
37
|
+
out.info(f"Checking outdated deps in {target}...")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
run_pnpm(["outdated"], cwd=cwd, capture=False, timeout=60)
|
|
41
|
+
except CommandError:
|
|
42
|
+
# pnpm outdated exits non-zero when outdated deps exist — this is expected
|
|
43
|
+
pass
|
|
44
|
+
except Exception as e:
|
|
45
|
+
out.error(f"Failed to check outdated deps: {e}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def audit(ctx: typer.Context) -> None:
|
|
50
|
+
"""Run security audit on dependencies."""
|
|
51
|
+
actx: AppContext = ctx.obj
|
|
52
|
+
out = actx.output
|
|
53
|
+
root = actx.project_root
|
|
54
|
+
|
|
55
|
+
out.info("Running pnpm audit...")
|
|
56
|
+
try:
|
|
57
|
+
run_pnpm(["audit"], cwd=root, capture=False, timeout=120)
|
|
58
|
+
out.success("No vulnerabilities found")
|
|
59
|
+
except Exception:
|
|
60
|
+
out.warn("Vulnerabilities detected (see above)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def graph(ctx: typer.Context) -> None:
|
|
65
|
+
"""Show internal package dependency graph."""
|
|
66
|
+
actx: AppContext = ctx.obj
|
|
67
|
+
out = actx.output
|
|
68
|
+
root = actx.project_root
|
|
69
|
+
|
|
70
|
+
nodes: list[dict] = []
|
|
71
|
+
|
|
72
|
+
# Read each app's package.json to find @kodemeio/* deps
|
|
73
|
+
for name in actx.app_names:
|
|
74
|
+
pkg_file = get_app_dir(root, name) / "package.json"
|
|
75
|
+
if not pkg_file.exists():
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
pkg = json.loads(pkg_file.read_text())
|
|
79
|
+
all_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
80
|
+
kodemeio_deps = sorted(k for k in all_deps if k.startswith("@kodemeio/"))
|
|
81
|
+
|
|
82
|
+
children = [{"name": dep} for dep in kodemeio_deps]
|
|
83
|
+
nodes.append(
|
|
84
|
+
{
|
|
85
|
+
"name": f"[cyan]{name}[/cyan]",
|
|
86
|
+
"info": f"{len(kodemeio_deps)} packages",
|
|
87
|
+
"children": children,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
out.tree(
|
|
92
|
+
"Package Dependency Graph",
|
|
93
|
+
nodes,
|
|
94
|
+
data_for_json=[{"app": n["name"], "deps": [c["name"] for c in n.get("children", [])]} for n in nodes],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("list")
|
|
99
|
+
def list_(ctx: typer.Context) -> None:
|
|
100
|
+
"""List all workspace packages."""
|
|
101
|
+
actx: AppContext = ctx.obj
|
|
102
|
+
out = actx.output
|
|
103
|
+
root = actx.project_root
|
|
104
|
+
|
|
105
|
+
rows: list[list[str]] = []
|
|
106
|
+
|
|
107
|
+
# Apps
|
|
108
|
+
for name in actx.app_names:
|
|
109
|
+
pkg_file = get_app_dir(root, name) / "package.json"
|
|
110
|
+
if not pkg_file.exists():
|
|
111
|
+
continue
|
|
112
|
+
pkg = json.loads(pkg_file.read_text())
|
|
113
|
+
rel_path = str(get_app_dir(root, name).relative_to(root))
|
|
114
|
+
rows.append([pkg.get("name", ""), pkg.get("version", ""), "app", rel_path])
|
|
115
|
+
|
|
116
|
+
# Packages
|
|
117
|
+
packages_dir = root / "packages"
|
|
118
|
+
if packages_dir.is_dir():
|
|
119
|
+
for pkg_dir in sorted(packages_dir.iterdir()):
|
|
120
|
+
pkg_file = pkg_dir / "package.json"
|
|
121
|
+
if not pkg_file.exists():
|
|
122
|
+
continue
|
|
123
|
+
pkg = json.loads(pkg_file.read_text())
|
|
124
|
+
rows.append([pkg.get("name", ""), pkg.get("version", ""), "package", f"packages/{pkg_dir.name}"])
|
|
125
|
+
|
|
126
|
+
out.table(
|
|
127
|
+
"Workspace Packages",
|
|
128
|
+
[("Package", "cyan"), ("Version", "green"), ("Type", ""), ("Path", "dim")],
|
|
129
|
+
rows,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def why(
|
|
135
|
+
ctx: typer.Context,
|
|
136
|
+
package: Annotated[str, typer.Argument(help="Package name (e.g. @tanstack/react-query)")],
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Show which apps depend on a specific package."""
|
|
139
|
+
actx: AppContext = ctx.obj
|
|
140
|
+
out = actx.output
|
|
141
|
+
root = actx.project_root
|
|
142
|
+
|
|
143
|
+
rows: list[list[str]] = []
|
|
144
|
+
json_data: list[dict] = []
|
|
145
|
+
|
|
146
|
+
for name in actx.app_names:
|
|
147
|
+
pkg_file = get_app_dir(root, name) / "package.json"
|
|
148
|
+
if not pkg_file.exists():
|
|
149
|
+
continue
|
|
150
|
+
pkg = json.loads(pkg_file.read_text())
|
|
151
|
+
deps = pkg.get("dependencies", {})
|
|
152
|
+
dev_deps = pkg.get("devDependencies", {})
|
|
153
|
+
|
|
154
|
+
if package in deps:
|
|
155
|
+
rows.append([name, deps[package], "dependency"])
|
|
156
|
+
json_data.append({"app": name, "version": deps[package], "type": "dependency"})
|
|
157
|
+
elif package in dev_deps:
|
|
158
|
+
rows.append([name, dev_deps[package], "devDependency"])
|
|
159
|
+
json_data.append({"app": name, "version": dev_deps[package], "type": "devDependency"})
|
|
160
|
+
|
|
161
|
+
# Also check shared packages
|
|
162
|
+
packages_dir = root / "packages"
|
|
163
|
+
if packages_dir.is_dir():
|
|
164
|
+
for pkg_dir in sorted(packages_dir.iterdir()):
|
|
165
|
+
pkg_file = pkg_dir / "package.json"
|
|
166
|
+
if not pkg_file.exists():
|
|
167
|
+
continue
|
|
168
|
+
pkg = json.loads(pkg_file.read_text())
|
|
169
|
+
deps = pkg.get("dependencies", {})
|
|
170
|
+
dev_deps = pkg.get("devDependencies", {})
|
|
171
|
+
pkg_name = pkg.get("name", pkg_dir.name)
|
|
172
|
+
|
|
173
|
+
if package in deps:
|
|
174
|
+
rows.append([pkg_name, deps[package], "dependency"])
|
|
175
|
+
json_data.append({"package": pkg_name, "version": deps[package], "type": "dependency"})
|
|
176
|
+
elif package in dev_deps:
|
|
177
|
+
rows.append([pkg_name, dev_deps[package], "devDependency"])
|
|
178
|
+
json_data.append({"package": pkg_name, "version": dev_deps[package], "type": "devDependency"})
|
|
179
|
+
|
|
180
|
+
if not rows:
|
|
181
|
+
out.info(f"No workspace package depends on {package}")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
out.table(
|
|
185
|
+
f"Who uses {package}?",
|
|
186
|
+
[("Package", "cyan"), ("Version", "green"), ("Type", "dim")],
|
|
187
|
+
rows,
|
|
188
|
+
data_for_json=json_data,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def duplicates(ctx: typer.Context) -> None:
|
|
194
|
+
"""Find packages with inconsistent versions across workspaces."""
|
|
195
|
+
actx: AppContext = ctx.obj
|
|
196
|
+
out = actx.output
|
|
197
|
+
root = actx.project_root
|
|
198
|
+
|
|
199
|
+
# Collect all deps from all package.json files
|
|
200
|
+
versions: dict[str, dict[str, str]] = {} # {dep: {workspace: version}}
|
|
201
|
+
|
|
202
|
+
def _scan_pkg(pkg_file: Path, workspace_name: str) -> None:
|
|
203
|
+
if not pkg_file.exists():
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
pkg = json.loads(pkg_file.read_text())
|
|
207
|
+
for dep_type in ("dependencies", "devDependencies"):
|
|
208
|
+
for dep, ver in pkg.get(dep_type, {}).items():
|
|
209
|
+
if dep.startswith("@kodemeio/") or ver.startswith("workspace:"):
|
|
210
|
+
continue
|
|
211
|
+
if dep not in versions:
|
|
212
|
+
versions[dep] = {}
|
|
213
|
+
versions[dep][workspace_name] = ver
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
for name in actx.app_names:
|
|
218
|
+
_scan_pkg(get_app_dir(root, name) / "package.json", name)
|
|
219
|
+
|
|
220
|
+
packages_dir = root / "packages"
|
|
221
|
+
if packages_dir.is_dir():
|
|
222
|
+
for pkg_dir in sorted(packages_dir.iterdir()):
|
|
223
|
+
_scan_pkg(pkg_dir / "package.json", f"pkg:{pkg_dir.name}")
|
|
224
|
+
|
|
225
|
+
# Find duplicates (same dep, different versions)
|
|
226
|
+
rows: list[list[str]] = []
|
|
227
|
+
json_data: list[dict] = []
|
|
228
|
+
|
|
229
|
+
for dep, workspaces in sorted(versions.items()):
|
|
230
|
+
unique_versions = set(workspaces.values())
|
|
231
|
+
if len(unique_versions) <= 1:
|
|
232
|
+
continue # All same version
|
|
233
|
+
for workspace, ver in sorted(workspaces.items()):
|
|
234
|
+
rows.append([dep, workspace, ver])
|
|
235
|
+
json_data.append({"package": dep, "versions": workspaces})
|
|
236
|
+
|
|
237
|
+
if not rows:
|
|
238
|
+
out.success("No version inconsistencies found")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
out.table(
|
|
242
|
+
"Version Inconsistencies",
|
|
243
|
+
[("Package", "cyan"), ("Workspace", ""), ("Version", "yellow")],
|
|
244
|
+
rows,
|
|
245
|
+
data_for_json=json_data,
|
|
246
|
+
)
|
|
247
|
+
out.warn(f"{len(json_data)} package(s) have inconsistent versions")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("size")
|
|
251
|
+
def node_modules_size(ctx: typer.Context) -> None:
|
|
252
|
+
"""Show node_modules disk usage."""
|
|
253
|
+
actx: AppContext = ctx.obj
|
|
254
|
+
out = actx.output
|
|
255
|
+
root = actx.project_root
|
|
256
|
+
|
|
257
|
+
nm = root / "node_modules"
|
|
258
|
+
if not nm.is_dir():
|
|
259
|
+
out.warn("node_modules not found — run `pnpm install`")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
out.info("Calculating node_modules size (may take a moment)...")
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
result = run(
|
|
266
|
+
["du", "-sh", str(nm)],
|
|
267
|
+
cwd=root,
|
|
268
|
+
capture=True,
|
|
269
|
+
timeout=30,
|
|
270
|
+
)
|
|
271
|
+
size = result.stdout.strip().split("\t")[0]
|
|
272
|
+
out.success(f"node_modules: {size}")
|
|
273
|
+
except Exception:
|
|
274
|
+
# Fallback: count in Python
|
|
275
|
+
total = 0
|
|
276
|
+
for f in nm.rglob("*"):
|
|
277
|
+
if f.is_file():
|
|
278
|
+
with contextlib.suppress(OSError):
|
|
279
|
+
total += f.stat().st_size
|
|
280
|
+
from kctl_react.commands.build import _format_size
|
|
281
|
+
|
|
282
|
+
out.success(f"node_modules: {_format_size(total)}")
|
|
283
|
+
|
|
284
|
+
if out.json_mode:
|
|
285
|
+
out.raw_json({"path": str(nm)})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_DEP_CATEGORIES: dict[str, str] = {
|
|
289
|
+
# Core framework
|
|
290
|
+
"react": "framework",
|
|
291
|
+
"react-dom": "framework",
|
|
292
|
+
"react-router-dom": "routing",
|
|
293
|
+
"typescript": "language",
|
|
294
|
+
# Build tools
|
|
295
|
+
"vite": "build",
|
|
296
|
+
"@vitejs/plugin-react": "build",
|
|
297
|
+
"vite-plugin-pwa": "build",
|
|
298
|
+
"tailwindcss": "styling",
|
|
299
|
+
"@tailwindcss/vite": "styling",
|
|
300
|
+
# State & data
|
|
301
|
+
"@tanstack/react-query": "state",
|
|
302
|
+
"@tanstack/react-table": "data-table",
|
|
303
|
+
"zustand": "state",
|
|
304
|
+
"idb": "offline",
|
|
305
|
+
# UI
|
|
306
|
+
"shadcn": "ui",
|
|
307
|
+
"lucide-react": "icons",
|
|
308
|
+
"sonner": "notifications",
|
|
309
|
+
"class-variance-authority": "ui",
|
|
310
|
+
"clsx": "ui",
|
|
311
|
+
"tailwind-merge": "ui",
|
|
312
|
+
"cmdk": "ui",
|
|
313
|
+
"tw-animate-css": "ui",
|
|
314
|
+
"echarts": "charts",
|
|
315
|
+
"echarts-for-react": "charts",
|
|
316
|
+
# Forms
|
|
317
|
+
"react-hook-form": "forms",
|
|
318
|
+
"zod": "validation",
|
|
319
|
+
"@hookform/resolvers": "forms",
|
|
320
|
+
# i18n
|
|
321
|
+
"i18next": "i18n",
|
|
322
|
+
"react-i18next": "i18n",
|
|
323
|
+
# Maps & GPS
|
|
324
|
+
"leaflet": "maps",
|
|
325
|
+
"react-leaflet": "maps",
|
|
326
|
+
# Auth & security
|
|
327
|
+
"dompurify": "security",
|
|
328
|
+
# Date
|
|
329
|
+
"date-fns": "date",
|
|
330
|
+
"react-day-picker": "date",
|
|
331
|
+
# Mobile
|
|
332
|
+
"@capacitor/core": "mobile",
|
|
333
|
+
"@capacitor/android": "mobile",
|
|
334
|
+
"@capacitor/cli": "mobile",
|
|
335
|
+
"@capacitor/app": "mobile",
|
|
336
|
+
"@capacitor/geolocation": "mobile",
|
|
337
|
+
"@capacitor/keyboard": "mobile",
|
|
338
|
+
"@capacitor/splash-screen": "mobile",
|
|
339
|
+
"@capacitor/status-bar": "mobile",
|
|
340
|
+
# Monitoring
|
|
341
|
+
"@sentry/react": "monitoring",
|
|
342
|
+
"@sentry/vite-plugin": "monitoring",
|
|
343
|
+
# Codegen
|
|
344
|
+
"@hey-api/openapi-ts": "codegen",
|
|
345
|
+
"@hey-api/client-fetch": "codegen",
|
|
346
|
+
# Notifications
|
|
347
|
+
"firebase": "push-notifications",
|
|
348
|
+
# Export
|
|
349
|
+
"xlsx": "export",
|
|
350
|
+
"html2canvas": "export",
|
|
351
|
+
# QR
|
|
352
|
+
"html5-qrcode": "qr",
|
|
353
|
+
"qrcode-generator": "qr",
|
|
354
|
+
"qrcode.react": "qr",
|
|
355
|
+
# Testing
|
|
356
|
+
"@testing-library/react": "testing",
|
|
357
|
+
# PWA
|
|
358
|
+
"workbox-window": "pwa",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _categorize_dep(name: str) -> str:
|
|
363
|
+
"""Return the category for a dependency name."""
|
|
364
|
+
if name in _DEP_CATEGORIES:
|
|
365
|
+
return _DEP_CATEGORIES[name]
|
|
366
|
+
if name.startswith("@radix-ui/"):
|
|
367
|
+
return "ui-primitives"
|
|
368
|
+
if name.startswith("@types/"):
|
|
369
|
+
return "types"
|
|
370
|
+
return "other"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _collect_all_external_deps(root: Path, app_names: list[str], packages: list[str]) -> dict[str, dict[str, str]]:
|
|
374
|
+
"""Collect ALL external dependencies across all workspaces.
|
|
375
|
+
|
|
376
|
+
Returns {dep_name: {workspace_name: version_string}}.
|
|
377
|
+
Skips @kodemeio/* and workspace:* versions.
|
|
378
|
+
"""
|
|
379
|
+
versions: dict[str, dict[str, str]] = {}
|
|
380
|
+
|
|
381
|
+
def _scan(pkg_file: Path, workspace: str) -> None:
|
|
382
|
+
if not pkg_file.exists():
|
|
383
|
+
return
|
|
384
|
+
try:
|
|
385
|
+
pkg = json.loads(pkg_file.read_text())
|
|
386
|
+
except Exception:
|
|
387
|
+
return
|
|
388
|
+
for dep_type in ("dependencies", "devDependencies"):
|
|
389
|
+
for dep, ver in pkg.get(dep_type, {}).items():
|
|
390
|
+
if dep.startswith("@kodemeio/"):
|
|
391
|
+
continue
|
|
392
|
+
if ver.startswith("workspace:"):
|
|
393
|
+
continue
|
|
394
|
+
if dep not in versions:
|
|
395
|
+
versions[dep] = {}
|
|
396
|
+
versions[dep][workspace] = ver
|
|
397
|
+
|
|
398
|
+
for name in app_names:
|
|
399
|
+
_scan(get_app_dir(root, name) / "package.json", name)
|
|
400
|
+
|
|
401
|
+
packages_dir = root / "packages"
|
|
402
|
+
if packages_dir.is_dir():
|
|
403
|
+
for pkg_name in packages:
|
|
404
|
+
_scan(packages_dir / pkg_name / "package.json", f"pkg:{pkg_name}")
|
|
405
|
+
|
|
406
|
+
return versions
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _semver_key(ver: str) -> list[int]:
|
|
410
|
+
"""Parse a semver-ish string into a sortable list of ints."""
|
|
411
|
+
clean = ver.lstrip("^~>=<")
|
|
412
|
+
parts = clean.split(".")
|
|
413
|
+
result: list[int] = []
|
|
414
|
+
for p in parts[:3]:
|
|
415
|
+
try:
|
|
416
|
+
result.append(int(p.split("-")[0]))
|
|
417
|
+
except ValueError:
|
|
418
|
+
result.append(0)
|
|
419
|
+
return result
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.command("upgrade")
|
|
423
|
+
def upgrade(
|
|
424
|
+
ctx: typer.Context,
|
|
425
|
+
category: Annotated[str | None, typer.Option("--category", help="Only upgrade deps in this category")] = None,
|
|
426
|
+
major: Annotated[bool, typer.Option("--major", help="Include major version bumps")] = False,
|
|
427
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would change")] = False,
|
|
428
|
+
) -> None:
|
|
429
|
+
"""Smart dependency upgrade — show outdated deps with context and apply upgrades.
|
|
430
|
+
|
|
431
|
+
Scans all external deps, checks for newer versions via `pnpm outdated --json`,
|
|
432
|
+
and shows a categorized upgrade plan. Use --dry-run to preview.
|
|
433
|
+
|
|
434
|
+
Examples:
|
|
435
|
+
kctl-react deps upgrade # Show upgrade plan
|
|
436
|
+
kctl-react deps upgrade --category build # Only build tools
|
|
437
|
+
kctl-react deps upgrade --major # Include major bumps
|
|
438
|
+
"""
|
|
439
|
+
actx: AppContext = ctx.obj
|
|
440
|
+
out = actx.output
|
|
441
|
+
root = actx.project_root
|
|
442
|
+
|
|
443
|
+
out.info("Checking for outdated dependencies...")
|
|
444
|
+
|
|
445
|
+
# Run pnpm outdated --json to get available updates
|
|
446
|
+
try:
|
|
447
|
+
result = run_pnpm(["outdated", "--json"], cwd=root, capture=True, timeout=120)
|
|
448
|
+
raw = result.stdout
|
|
449
|
+
except CommandError as e:
|
|
450
|
+
# pnpm outdated exits non-zero when outdated deps exist — parse stderr/stdout
|
|
451
|
+
raw = e.stderr or ""
|
|
452
|
+
if not raw:
|
|
453
|
+
# Try to get stdout from the exception detail
|
|
454
|
+
raw = str(e)
|
|
455
|
+
|
|
456
|
+
# Parse JSON output — pnpm outdated --json format varies by version
|
|
457
|
+
outdated: dict[str, dict] = {}
|
|
458
|
+
try:
|
|
459
|
+
data = json.loads(raw)
|
|
460
|
+
# pnpm v9 format: dict of {pkg_name: {current, latest, wanted, ...}}
|
|
461
|
+
if isinstance(data, dict):
|
|
462
|
+
for pkg_name, info in data.items():
|
|
463
|
+
if isinstance(info, dict) and "current" in info:
|
|
464
|
+
outdated[pkg_name] = info
|
|
465
|
+
except (json.JSONDecodeError, ValueError):
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
if not outdated:
|
|
469
|
+
# Fallback: use our own dep inventory to show all deps with versions
|
|
470
|
+
out.info("Could not parse pnpm outdated output — showing dep inventory instead")
|
|
471
|
+
all_deps = _collect_all_external_deps(root, actx.app_names, actx.packages)
|
|
472
|
+
rows: list[list[str]] = []
|
|
473
|
+
json_data: list[dict] = []
|
|
474
|
+
for dep_name in sorted(all_deps):
|
|
475
|
+
cat = _categorize_dep(dep_name)
|
|
476
|
+
if category and cat != category:
|
|
477
|
+
continue
|
|
478
|
+
versions = sorted(set(all_deps[dep_name].values()))
|
|
479
|
+
rows.append([dep_name, ", ".join(versions), cat, str(len(all_deps[dep_name]))])
|
|
480
|
+
json_data.append({"package": dep_name, "versions": versions, "category": cat})
|
|
481
|
+
out.table(
|
|
482
|
+
"Dependencies (run `pnpm outdated` manually for update info)",
|
|
483
|
+
[("Package", "cyan"), ("Current", "green"), ("Category", ""), ("Used By", "dim")],
|
|
484
|
+
rows,
|
|
485
|
+
data_for_json=json_data,
|
|
486
|
+
)
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
# Build upgrade plan
|
|
490
|
+
rows = []
|
|
491
|
+
json_data = []
|
|
492
|
+
minor_count = 0
|
|
493
|
+
major_count = 0
|
|
494
|
+
|
|
495
|
+
for pkg_name in sorted(outdated):
|
|
496
|
+
info = outdated[pkg_name]
|
|
497
|
+
current = info.get("current", "?")
|
|
498
|
+
wanted = info.get("wanted", current)
|
|
499
|
+
latest = info.get("latest", current)
|
|
500
|
+
cat = _categorize_dep(pkg_name)
|
|
501
|
+
|
|
502
|
+
if category and cat != category:
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
# Determine if this is a major bump
|
|
506
|
+
current_major = _semver_key(current)[0] if current != "?" else 0
|
|
507
|
+
latest_major = _semver_key(latest)[0]
|
|
508
|
+
is_major = latest_major > current_major
|
|
509
|
+
|
|
510
|
+
if is_major:
|
|
511
|
+
major_count += 1
|
|
512
|
+
if not major:
|
|
513
|
+
continue # Skip major bumps unless --major flag
|
|
514
|
+
bump_type = "[red]MAJOR[/red]"
|
|
515
|
+
else:
|
|
516
|
+
minor_count += 1
|
|
517
|
+
bump_type = "[green]minor[/green]" if wanted != current else "[dim]patch[/dim]"
|
|
518
|
+
|
|
519
|
+
rows.append([pkg_name, current, wanted, latest, cat, bump_type])
|
|
520
|
+
json_data.append(
|
|
521
|
+
{
|
|
522
|
+
"package": pkg_name,
|
|
523
|
+
"current": current,
|
|
524
|
+
"wanted": wanted,
|
|
525
|
+
"latest": latest,
|
|
526
|
+
"category": cat,
|
|
527
|
+
"major": is_major,
|
|
528
|
+
}
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if not rows:
|
|
532
|
+
out.success("All dependencies are up to date" + (f" (category: {category})" if category else ""))
|
|
533
|
+
if major_count > 0:
|
|
534
|
+
out.info(f"{major_count} major upgrade(s) available — use --major to include")
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
out.table(
|
|
538
|
+
f"Upgrade Plan ({len(rows)} packages)",
|
|
539
|
+
[
|
|
540
|
+
("Package", "cyan"),
|
|
541
|
+
("Current", "dim"),
|
|
542
|
+
("Wanted", "green"),
|
|
543
|
+
("Latest", "yellow"),
|
|
544
|
+
("Category", ""),
|
|
545
|
+
("Type", ""),
|
|
546
|
+
],
|
|
547
|
+
rows,
|
|
548
|
+
data_for_json=json_data,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if major_count > 0 and not major:
|
|
552
|
+
out.info(f"{major_count} major upgrade(s) hidden — use --major to include")
|
|
553
|
+
|
|
554
|
+
if dry_run:
|
|
555
|
+
out.info("[dry-run] No changes applied")
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
# Apply upgrades
|
|
559
|
+
out.info("Applying upgrades via pnpm update...")
|
|
560
|
+
try:
|
|
561
|
+
cmd = ["update"]
|
|
562
|
+
if not major:
|
|
563
|
+
cmd.append("--no-save") # Only update within semver range
|
|
564
|
+
run_pnpm(cmd, cwd=root, capture=False, timeout=300)
|
|
565
|
+
out.success(f"Upgraded {len(rows)} package(s)")
|
|
566
|
+
except CommandError as exc:
|
|
567
|
+
out.error(f"Upgrade failed: {exc}")
|
|
568
|
+
raise typer.Exit(1) from exc
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@app.command("stack")
|
|
572
|
+
def stack(
|
|
573
|
+
ctx: typer.Context,
|
|
574
|
+
category: Annotated[str | None, typer.Option("--category", help="Filter by category")] = None,
|
|
575
|
+
) -> None:
|
|
576
|
+
"""Show full external dependency inventory across the monorepo."""
|
|
577
|
+
actx: AppContext = ctx.obj
|
|
578
|
+
out = actx.output
|
|
579
|
+
root = actx.project_root
|
|
580
|
+
|
|
581
|
+
out.info("Scanning all external dependencies...")
|
|
582
|
+
|
|
583
|
+
all_deps = _collect_all_external_deps(root, actx.app_names, actx.packages)
|
|
584
|
+
|
|
585
|
+
rows: list[list[str]] = []
|
|
586
|
+
json_data: list[dict] = []
|
|
587
|
+
|
|
588
|
+
for dep_name in sorted(all_deps):
|
|
589
|
+
workspaces = all_deps[dep_name]
|
|
590
|
+
cat = _categorize_dep(dep_name)
|
|
591
|
+
|
|
592
|
+
if category and cat != category:
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
unique_versions = sorted(set(workspaces.values()))
|
|
596
|
+
version_str = ", ".join(unique_versions)
|
|
597
|
+
used_by = len(workspaces)
|
|
598
|
+
status = "ok" if len(unique_versions) == 1 else "inconsistent"
|
|
599
|
+
|
|
600
|
+
rows.append([dep_name, version_str, cat, str(used_by), status])
|
|
601
|
+
json_data.append(
|
|
602
|
+
{
|
|
603
|
+
"package": dep_name,
|
|
604
|
+
"versions": unique_versions,
|
|
605
|
+
"category": cat,
|
|
606
|
+
"used_by": used_by,
|
|
607
|
+
"status": status,
|
|
608
|
+
"workspaces": workspaces,
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if not rows:
|
|
613
|
+
out.info("No external dependencies found" + (f" in category '{category}'" if category else ""))
|
|
614
|
+
if out.json_mode:
|
|
615
|
+
out.raw_json([])
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
out.table(
|
|
619
|
+
"External Dependency Inventory",
|
|
620
|
+
[
|
|
621
|
+
("Package", "cyan"),
|
|
622
|
+
("Version(s)", "green"),
|
|
623
|
+
("Category", ""),
|
|
624
|
+
("Used By", ""),
|
|
625
|
+
("Status", "yellow"),
|
|
626
|
+
],
|
|
627
|
+
rows,
|
|
628
|
+
data_for_json=json_data,
|
|
629
|
+
)
|
|
630
|
+
out.info(f"{len(rows)} external dependencies found")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@app.command("health")
|
|
634
|
+
def health(ctx: typer.Context) -> None:
|
|
635
|
+
"""Run dependency health checks and produce an overall score."""
|
|
636
|
+
actx: AppContext = ctx.obj
|
|
637
|
+
out = actx.output
|
|
638
|
+
root = actx.project_root
|
|
639
|
+
|
|
640
|
+
out.info("Running dependency health checks...")
|
|
641
|
+
|
|
642
|
+
all_deps = _collect_all_external_deps(root, actx.app_names, actx.packages)
|
|
643
|
+
total_deps = len(all_deps)
|
|
644
|
+
|
|
645
|
+
# Check 1: Version inconsistencies
|
|
646
|
+
inconsistent_count = 0
|
|
647
|
+
for workspaces in all_deps.values():
|
|
648
|
+
if len(set(workspaces.values())) > 1:
|
|
649
|
+
inconsistent_count += 1
|
|
650
|
+
|
|
651
|
+
# Check 2: Lockfile exists and is committed
|
|
652
|
+
lockfile = root / "pnpm-lock.yaml"
|
|
653
|
+
lockfile_exists = lockfile.exists()
|
|
654
|
+
lockfile_committed = False
|
|
655
|
+
if lockfile_exists:
|
|
656
|
+
try:
|
|
657
|
+
result = run(
|
|
658
|
+
["git", "ls-files", "--error-unmatch", "pnpm-lock.yaml"],
|
|
659
|
+
cwd=root,
|
|
660
|
+
capture=True,
|
|
661
|
+
timeout=10,
|
|
662
|
+
)
|
|
663
|
+
lockfile_committed = result.returncode == 0
|
|
664
|
+
except Exception:
|
|
665
|
+
lockfile_committed = False
|
|
666
|
+
|
|
667
|
+
# Build checks table
|
|
668
|
+
checks: list[dict] = [
|
|
669
|
+
{
|
|
670
|
+
"check": "Version consistency",
|
|
671
|
+
"status": "pass" if inconsistent_count == 0 else "fail",
|
|
672
|
+
"detail": f"{inconsistent_count} inconsistencies across {total_deps} deps",
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
"check": "Lockfile exists",
|
|
676
|
+
"status": "pass" if lockfile_exists else "fail",
|
|
677
|
+
"detail": "pnpm-lock.yaml present" if lockfile_exists else "pnpm-lock.yaml missing",
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
"check": "Lockfile committed",
|
|
681
|
+
"status": "pass" if lockfile_committed else "fail",
|
|
682
|
+
"detail": "Tracked in git" if lockfile_committed else "Not tracked in git",
|
|
683
|
+
},
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
# Calculate score
|
|
687
|
+
passed = sum(1 for c in checks if c["status"] == "pass")
|
|
688
|
+
total_checks = len(checks)
|
|
689
|
+
score = int(passed / total_checks * 100)
|
|
690
|
+
|
|
691
|
+
rows = [[c["check"], c["status"], c["detail"]] for c in checks]
|
|
692
|
+
rows.append(["Overall score", f"{score}%", f"{passed}/{total_checks} checks passed"])
|
|
693
|
+
|
|
694
|
+
out.table(
|
|
695
|
+
"Dependency Health",
|
|
696
|
+
[("Check", "cyan"), ("Status", "green"), ("Detail", "dim")],
|
|
697
|
+
rows,
|
|
698
|
+
data_for_json={"checks": checks, "score": score, "total_deps": total_deps},
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
if score == 100:
|
|
702
|
+
out.success(f"Dependency health: {score}%")
|
|
703
|
+
else:
|
|
704
|
+
out.warn(f"Dependency health: {score}%")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@app.command("sync")
|
|
708
|
+
def sync(
|
|
709
|
+
ctx: typer.Context,
|
|
710
|
+
fix: Annotated[bool, typer.Option("--fix", help="Update all to highest version and run pnpm install")] = False,
|
|
711
|
+
) -> None:
|
|
712
|
+
"""Check and fix version inconsistencies across ALL external dependencies."""
|
|
713
|
+
actx: AppContext = ctx.obj
|
|
714
|
+
out = actx.output
|
|
715
|
+
root = actx.project_root
|
|
716
|
+
|
|
717
|
+
out.info("Scanning dependency versions...")
|
|
718
|
+
|
|
719
|
+
versions = _collect_all_external_deps(root, actx.app_names, actx.packages)
|
|
720
|
+
|
|
721
|
+
rows: list[list[str]] = []
|
|
722
|
+
json_data: list[dict] = []
|
|
723
|
+
inconsistent: dict[str, dict[str, str]] = {}
|
|
724
|
+
|
|
725
|
+
for dep in sorted(versions):
|
|
726
|
+
workspaces = versions[dep]
|
|
727
|
+
unique_versions = set(workspaces.values())
|
|
728
|
+
if len(unique_versions) <= 1:
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
inconsistent[dep] = workspaces
|
|
732
|
+
version_list = ", ".join(f"{v} ({w})" for w, v in sorted(workspaces.items()))
|
|
733
|
+
rows.append([dep, str(len(unique_versions)), version_list])
|
|
734
|
+
json_data.append(
|
|
735
|
+
{
|
|
736
|
+
"package": dep,
|
|
737
|
+
"version_count": len(unique_versions),
|
|
738
|
+
"workspaces": workspaces,
|
|
739
|
+
}
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if not inconsistent:
|
|
743
|
+
out.success("All external dependencies are in sync")
|
|
744
|
+
if out.json_mode:
|
|
745
|
+
out.raw_json({"inconsistencies": [], "total": 0})
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
out.table(
|
|
749
|
+
"Version Inconsistencies",
|
|
750
|
+
[("Package", "cyan"), ("Versions", "yellow"), ("Details", "dim")],
|
|
751
|
+
rows,
|
|
752
|
+
data_for_json=json_data,
|
|
753
|
+
)
|
|
754
|
+
out.warn(f"{len(inconsistent)} package(s) have inconsistent versions")
|
|
755
|
+
|
|
756
|
+
if not fix:
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
out.info("Fixing version inconsistencies...")
|
|
760
|
+
for dep, workspaces in inconsistent.items():
|
|
761
|
+
highest_ver = max(workspaces.values(), key=_semver_key)
|
|
762
|
+
out.info(f" {dep}: -> {highest_ver}")
|
|
763
|
+
|
|
764
|
+
for workspace in workspaces:
|
|
765
|
+
if workspace.startswith("pkg:"):
|
|
766
|
+
pkg_name = workspace[4:]
|
|
767
|
+
pkg_file = root / "packages" / pkg_name / "package.json"
|
|
768
|
+
else:
|
|
769
|
+
pkg_file = get_app_dir(root, workspace) / "package.json"
|
|
770
|
+
|
|
771
|
+
if not pkg_file.exists():
|
|
772
|
+
continue
|
|
773
|
+
|
|
774
|
+
try:
|
|
775
|
+
pkg = json.loads(pkg_file.read_text())
|
|
776
|
+
changed = False
|
|
777
|
+
for dep_type in ("dependencies", "devDependencies"):
|
|
778
|
+
if dep in pkg.get(dep_type, {}):
|
|
779
|
+
pkg[dep_type][dep] = highest_ver
|
|
780
|
+
changed = True
|
|
781
|
+
if changed:
|
|
782
|
+
pkg_file.write_text(json.dumps(pkg, indent=2) + "\n")
|
|
783
|
+
except Exception as e:
|
|
784
|
+
out.warn(f" Could not update {pkg_file}: {e}")
|
|
785
|
+
|
|
786
|
+
out.info("Running pnpm install...")
|
|
787
|
+
try:
|
|
788
|
+
run_pnpm(["install"], cwd=root, capture=False, timeout=120)
|
|
789
|
+
out.success("Dependencies synced and installed")
|
|
790
|
+
except Exception as e:
|
|
791
|
+
out.error(f"pnpm install failed: {e}")
|
|
792
|
+
raise typer.Exit(1) from None
|