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