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,889 @@
|
|
|
1
|
+
"""UI component audit — detect shadcn violations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from kctl_react.core.analyzers import find_raw_html_elements, scan_imports
|
|
16
|
+
from kctl_react.core.callbacks import AppContext
|
|
17
|
+
from kctl_react.core.exceptions import CommandError
|
|
18
|
+
from kctl_react.core.runner import run
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="UI component audit and shadcn compliance.")
|
|
21
|
+
|
|
22
|
+
# React anti-patterns to detect
|
|
23
|
+
_ANTI_PATTERNS: list[tuple[str, str, str]] = [
|
|
24
|
+
# (pattern, name, fix)
|
|
25
|
+
(r"style=\{\{", "inline style", "Use Tailwind utility classes instead"),
|
|
26
|
+
(r'from\s+["\'][^"\']+\.module\.css["\']', "CSS module import", "Use Tailwind v4 utility classes"),
|
|
27
|
+
(r'from\s+["\']styled-components["\']', "styled-components import", "Use Tailwind v4 utility classes"),
|
|
28
|
+
(r"document\.getElementById\(", "direct DOM access", "Use React refs (useRef) instead"),
|
|
29
|
+
(
|
|
30
|
+
r'from\s+["\'][^"\']*tailwind\.config["\']',
|
|
31
|
+
"tailwind.config import",
|
|
32
|
+
"Use CSS @theme in index.css (Tailwind v4)",
|
|
33
|
+
),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scan_anti_patterns(src_dir: Path) -> list[dict]:
|
|
38
|
+
"""Scan all TSX/TS files in src_dir for React anti-patterns."""
|
|
39
|
+
results: list[dict] = []
|
|
40
|
+
for f in src_dir.rglob("*.ts"):
|
|
41
|
+
_check_file_anti_patterns(f, results, src_dir)
|
|
42
|
+
for f in src_dir.rglob("*.tsx"):
|
|
43
|
+
_check_file_anti_patterns(f, results, src_dir)
|
|
44
|
+
return results
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_file_anti_patterns(file_path: Path, results: list[dict], base_dir: Path) -> None:
|
|
48
|
+
try:
|
|
49
|
+
lines = file_path.read_text(errors="ignore").splitlines()
|
|
50
|
+
except OSError:
|
|
51
|
+
return
|
|
52
|
+
rel = str(file_path.relative_to(base_dir.parent.parent))
|
|
53
|
+
for line_num, line in enumerate(lines, start=1):
|
|
54
|
+
for pattern, name, fix in _ANTI_PATTERNS:
|
|
55
|
+
if re.search(pattern, line):
|
|
56
|
+
results.append(
|
|
57
|
+
{
|
|
58
|
+
"file": rel,
|
|
59
|
+
"line": line_num,
|
|
60
|
+
"pattern": name,
|
|
61
|
+
"fix": fix,
|
|
62
|
+
"code": line.strip()[:80],
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def audit(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
69
|
+
"""Find raw HTML elements that should be shadcn components."""
|
|
70
|
+
actx: AppContext = ctx.obj
|
|
71
|
+
out = actx.output
|
|
72
|
+
actx.validate_app(app_name)
|
|
73
|
+
src_dir = actx.get_app_dir(app_name) / "src"
|
|
74
|
+
violations: list[dict] = []
|
|
75
|
+
for f in src_dir.rglob("*.tsx"):
|
|
76
|
+
for v in find_raw_html_elements(f):
|
|
77
|
+
# Normalise file path to be relative to project root
|
|
78
|
+
v = dict(v)
|
|
79
|
+
with contextlib.suppress(ValueError):
|
|
80
|
+
v["file"] = str(Path(str(v["file"])).relative_to(actx.project_root))
|
|
81
|
+
violations.append(v)
|
|
82
|
+
if not violations:
|
|
83
|
+
out.success("No shadcn violations found")
|
|
84
|
+
return
|
|
85
|
+
rows = [
|
|
86
|
+
[v["file"], str(v["line"]), v["element"], f"Use <{v['replacement'].split('+')[0].strip()}>"]
|
|
87
|
+
for v in violations[:50]
|
|
88
|
+
]
|
|
89
|
+
out.table(
|
|
90
|
+
f"UI Violations ({len(violations)} found)",
|
|
91
|
+
[("File", "red"), ("Line", "dim"), ("Element", "yellow"), ("Fix", "green")],
|
|
92
|
+
rows,
|
|
93
|
+
data_for_json=violations[:50],
|
|
94
|
+
)
|
|
95
|
+
if len(violations) > 50:
|
|
96
|
+
out.warn(f"Showing 50/{len(violations)} — use --json for full list")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def compliance(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
101
|
+
"""Score shadcn compliance as % of clean TSX files (no violations)."""
|
|
102
|
+
actx: AppContext = ctx.obj
|
|
103
|
+
out = actx.output
|
|
104
|
+
actx.validate_app(app_name)
|
|
105
|
+
src_dir = actx.get_app_dir(app_name) / "src"
|
|
106
|
+
|
|
107
|
+
total_files = 0
|
|
108
|
+
clean_files = 0
|
|
109
|
+
total_violations = 0
|
|
110
|
+
violation_by_file: dict[str, int] = {}
|
|
111
|
+
|
|
112
|
+
for f in src_dir.rglob("*.tsx"):
|
|
113
|
+
total_files += 1
|
|
114
|
+
file_violations = find_raw_html_elements(f)
|
|
115
|
+
count = len(file_violations)
|
|
116
|
+
total_violations += count
|
|
117
|
+
if count == 0:
|
|
118
|
+
clean_files += 1
|
|
119
|
+
else:
|
|
120
|
+
rel = str(f.relative_to(actx.project_root))
|
|
121
|
+
violation_by_file[rel] = count
|
|
122
|
+
|
|
123
|
+
if total_files == 0:
|
|
124
|
+
out.warn("No TSX files found")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
score = round(clean_files / total_files * 100, 1)
|
|
128
|
+
status = "✓ PASS" if score >= 80 else "✗ FAIL"
|
|
129
|
+
|
|
130
|
+
rows = [
|
|
131
|
+
[
|
|
132
|
+
app_name,
|
|
133
|
+
f"{score}%",
|
|
134
|
+
str(total_files),
|
|
135
|
+
str(clean_files),
|
|
136
|
+
str(total_violations),
|
|
137
|
+
status,
|
|
138
|
+
]
|
|
139
|
+
]
|
|
140
|
+
json_data = [
|
|
141
|
+
{
|
|
142
|
+
"app": app_name,
|
|
143
|
+
"score_pct": score,
|
|
144
|
+
"total_files": total_files,
|
|
145
|
+
"clean_files": clean_files,
|
|
146
|
+
"violation_count": total_violations,
|
|
147
|
+
"status": status,
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
out.table(
|
|
151
|
+
f"shadcn Compliance — {app_name}",
|
|
152
|
+
[
|
|
153
|
+
("App", "cyan"),
|
|
154
|
+
("Score", "green" if score >= 80 else "red"),
|
|
155
|
+
("Total Files", ""),
|
|
156
|
+
("Clean Files", ""),
|
|
157
|
+
("Violations", "yellow"),
|
|
158
|
+
("Status", ""),
|
|
159
|
+
],
|
|
160
|
+
rows,
|
|
161
|
+
data_for_json=json_data,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if violation_by_file:
|
|
165
|
+
top = sorted(violation_by_file.items(), key=lambda x: -x[1])[:10]
|
|
166
|
+
detail_rows = [[f, str(c)] for f, c in top]
|
|
167
|
+
out.table(
|
|
168
|
+
"Top Violating Files",
|
|
169
|
+
[("File", "red"), ("Violations", "yellow")],
|
|
170
|
+
detail_rows,
|
|
171
|
+
data_for_json=[{"file": f, "violations": c} for f, c in top],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command("anti-patterns")
|
|
176
|
+
def anti_patterns(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
177
|
+
"""Detect React anti-patterns: inline styles, CSS modules, styled-components, direct DOM access."""
|
|
178
|
+
actx: AppContext = ctx.obj
|
|
179
|
+
out = actx.output
|
|
180
|
+
actx.validate_app(app_name)
|
|
181
|
+
src_dir = actx.get_app_dir(app_name) / "src"
|
|
182
|
+
issues = _scan_anti_patterns(src_dir)
|
|
183
|
+
if not issues:
|
|
184
|
+
out.success("No React anti-patterns detected")
|
|
185
|
+
return
|
|
186
|
+
rows = [[v["file"], str(v["line"]), v["pattern"], v["fix"]] for v in issues[:50]]
|
|
187
|
+
out.table(
|
|
188
|
+
f"React Anti-Patterns ({len(issues)} found)",
|
|
189
|
+
[("File", "red"), ("Line", "dim"), ("Pattern", "yellow"), ("Fix", "green")],
|
|
190
|
+
rows,
|
|
191
|
+
data_for_json=issues[:50],
|
|
192
|
+
)
|
|
193
|
+
if len(issues) > 50:
|
|
194
|
+
out.warn(f"Showing 50/{len(issues)} — use --json for full list")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def components(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
199
|
+
"""List shadcn components used by the app."""
|
|
200
|
+
actx: AppContext = ctx.obj
|
|
201
|
+
out = actx.output
|
|
202
|
+
actx.validate_app(app_name)
|
|
203
|
+
src_dir = actx.get_app_dir(app_name) / "src"
|
|
204
|
+
imports: dict[str, int] = {}
|
|
205
|
+
for f in src_dir.rglob("*.tsx"):
|
|
206
|
+
for name in scan_imports(f, "@kodemeio/ui"):
|
|
207
|
+
imports[name] = imports.get(name, 0) + 1
|
|
208
|
+
if not imports:
|
|
209
|
+
out.warn("No @kodemeio/ui imports found")
|
|
210
|
+
return
|
|
211
|
+
rows = [[comp, str(count)] for comp, count in sorted(imports.items(), key=lambda x: -x[1])]
|
|
212
|
+
out.table(
|
|
213
|
+
f"shadcn Components — {app_name}",
|
|
214
|
+
[("Component", "cyan"), ("Imports", "")],
|
|
215
|
+
rows,
|
|
216
|
+
data_for_json=[{"component": c, "imports": n} for c, n in sorted(imports.items(), key=lambda x: -x[1])],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command("theme-check")
|
|
221
|
+
def theme_check(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
|
|
222
|
+
"""Validate theme CSS variable references and Tailwind v4 patterns."""
|
|
223
|
+
actx: AppContext = ctx.obj
|
|
224
|
+
out = actx.output
|
|
225
|
+
actx.validate_app(app_name)
|
|
226
|
+
theme_file = actx.project_root / "packages" / "tailwind-config" / "themes" / f"{app_name}.css"
|
|
227
|
+
if not theme_file.exists():
|
|
228
|
+
out.error(f"No theme file: {theme_file}")
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
theme_content = theme_file.read_text()
|
|
231
|
+
defined_vars = set(re.findall(r"--([a-z0-9-]+)\s*:", theme_content))
|
|
232
|
+
# Check source for var(--xxx) references
|
|
233
|
+
src_dir = actx.get_app_dir(app_name) / "src"
|
|
234
|
+
used_vars: set[str] = set()
|
|
235
|
+
for f in src_dir.rglob("*.tsx"):
|
|
236
|
+
content = f.read_text(errors="ignore")
|
|
237
|
+
used_vars.update(re.findall(r"var\(--([a-z0-9-]+)\)", content))
|
|
238
|
+
missing = used_vars - defined_vars
|
|
239
|
+
if missing:
|
|
240
|
+
out.warn(f"{len(missing)} CSS variable(s) used but not defined in theme:")
|
|
241
|
+
for v in sorted(missing):
|
|
242
|
+
out.text(f" --{v}")
|
|
243
|
+
else:
|
|
244
|
+
out.success("All CSS variables properly defined")
|
|
245
|
+
|
|
246
|
+
# Tailwind v4 pattern checks
|
|
247
|
+
issues: list[str] = []
|
|
248
|
+
index_css = src_dir / "index.css"
|
|
249
|
+
if index_css.exists():
|
|
250
|
+
css_content = index_css.read_text()
|
|
251
|
+
if '@import "tailwindcss"' not in css_content and "@import 'tailwindcss'" not in css_content:
|
|
252
|
+
issues.append("index.css missing '@import \"tailwindcss\"' (Tailwind v4 pattern)")
|
|
253
|
+
else:
|
|
254
|
+
issues.append("src/index.css not found")
|
|
255
|
+
|
|
256
|
+
vite_cfg = actx.get_app_dir(app_name) / "vite.config.ts"
|
|
257
|
+
if vite_cfg.exists():
|
|
258
|
+
vite_content = vite_cfg.read_text()
|
|
259
|
+
if "@tailwindcss/vite" not in vite_content:
|
|
260
|
+
issues.append("vite.config.ts missing '@tailwindcss/vite' plugin (Tailwind v4 pattern)")
|
|
261
|
+
else:
|
|
262
|
+
issues.append("vite.config.ts not found")
|
|
263
|
+
|
|
264
|
+
if issues:
|
|
265
|
+
out.warn(f"{len(issues)} Tailwind v4 issue(s):")
|
|
266
|
+
for issue in issues:
|
|
267
|
+
out.text(f" • {issue}")
|
|
268
|
+
else:
|
|
269
|
+
out.success("Tailwind v4 patterns verified")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── shadcn CLI wrapper commands ──────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
# Known custom components (not from the shadcn registry)
|
|
275
|
+
_CUSTOM_COMPONENTS = frozenset(
|
|
276
|
+
{
|
|
277
|
+
"data-table",
|
|
278
|
+
"file-upload",
|
|
279
|
+
"image-gallery",
|
|
280
|
+
"signature-pad",
|
|
281
|
+
"timeline",
|
|
282
|
+
"tour-overlay",
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.command()
|
|
288
|
+
def add(
|
|
289
|
+
ctx: typer.Context,
|
|
290
|
+
component: Annotated[str | None, typer.Argument(help="Component name (e.g. button, card)")] = None,
|
|
291
|
+
all_components: Annotated[bool, typer.Option("--all", "-a", help="Install all components")] = False,
|
|
292
|
+
overwrite: Annotated[bool, typer.Option("--overwrite", "-o", help="Overwrite existing")] = False,
|
|
293
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview changes")] = False,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Install a shadcn component into @kodemeio/ui."""
|
|
296
|
+
actx: AppContext = ctx.obj
|
|
297
|
+
out = actx.output
|
|
298
|
+
if not component and not all_components:
|
|
299
|
+
out.error("Specify a component name or use --all")
|
|
300
|
+
raise typer.Exit(1)
|
|
301
|
+
cmd = ["npx", "shadcn@latest", "add"]
|
|
302
|
+
if component:
|
|
303
|
+
cmd.append(component)
|
|
304
|
+
if all_components:
|
|
305
|
+
cmd.append("-a")
|
|
306
|
+
cmd.extend(["-c", "packages/ui", "-y"])
|
|
307
|
+
if overwrite:
|
|
308
|
+
cmd.append("-o")
|
|
309
|
+
if dry_run:
|
|
310
|
+
cmd.append("--dry-run")
|
|
311
|
+
try:
|
|
312
|
+
run(cmd, cwd=actx.project_root, capture=False)
|
|
313
|
+
except CommandError as exc:
|
|
314
|
+
out.error(str(exc))
|
|
315
|
+
raise typer.Exit(1) from exc
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command("diff")
|
|
319
|
+
def diff_cmd(
|
|
320
|
+
ctx: typer.Context,
|
|
321
|
+
component: Annotated[str, typer.Argument(help="Component name")],
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Show diff for a shadcn component update."""
|
|
324
|
+
actx: AppContext = ctx.obj
|
|
325
|
+
out = actx.output
|
|
326
|
+
cmd = ["npx", "shadcn@latest", "add", component, "--diff", "-c", "packages/ui"]
|
|
327
|
+
try:
|
|
328
|
+
run(cmd, cwd=actx.project_root, capture=False)
|
|
329
|
+
except CommandError as exc:
|
|
330
|
+
out.error(str(exc))
|
|
331
|
+
raise typer.Exit(1) from exc
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.command()
|
|
335
|
+
def search(
|
|
336
|
+
ctx: typer.Context,
|
|
337
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
338
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Search the shadcn component registry."""
|
|
341
|
+
actx: AppContext = ctx.obj
|
|
342
|
+
out = actx.output
|
|
343
|
+
cmd = ["npx", "shadcn@latest", "search", "@shadcn", "-q", query, "-l", str(limit)]
|
|
344
|
+
try:
|
|
345
|
+
run(cmd, cwd=actx.project_root, capture=False)
|
|
346
|
+
except CommandError as exc:
|
|
347
|
+
out.error(str(exc))
|
|
348
|
+
raise typer.Exit(1) from exc
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app.command()
|
|
352
|
+
def docs(
|
|
353
|
+
ctx: typer.Context,
|
|
354
|
+
component: Annotated[str, typer.Argument(help="Component name")],
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Get shadcn component documentation and API reference."""
|
|
357
|
+
actx: AppContext = ctx.obj
|
|
358
|
+
out = actx.output
|
|
359
|
+
cmd = ["npx", "shadcn@latest", "docs", component, "-c", "packages/ui"]
|
|
360
|
+
try:
|
|
361
|
+
run(cmd, cwd=actx.project_root, capture=False)
|
|
362
|
+
except CommandError as exc:
|
|
363
|
+
out.error(str(exc))
|
|
364
|
+
raise typer.Exit(1) from exc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
_VALID_APPS = frozenset({"sfa", "lfa", "shop", "wms", "bia", "eam", "mrp", "hrm", "tpm", "dms", "saas"})
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _extract_css_blocks(css: str) -> dict[str, str]:
|
|
371
|
+
"""Extract structured CSS blocks from shadcn-generated index.css.
|
|
372
|
+
|
|
373
|
+
Returns a dict with keys: ":root", ".dark", "@theme", "@layer".
|
|
374
|
+
Values are the inner content of each block (without the braces).
|
|
375
|
+
"""
|
|
376
|
+
blocks: dict[str, str] = {}
|
|
377
|
+
|
|
378
|
+
# Extract @theme inline { ... }
|
|
379
|
+
m = re.search(r"@theme\s+inline\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}", css, re.DOTALL)
|
|
380
|
+
if m:
|
|
381
|
+
blocks["@theme"] = m.group(1).strip()
|
|
382
|
+
|
|
383
|
+
# Extract top-level :root { ... } (not inside @layer)
|
|
384
|
+
# Find :root blocks that are NOT preceded by @layer
|
|
385
|
+
for m in re.finditer(r"(?<!\w):root\s*\{([^}]*)\}", css, re.DOTALL):
|
|
386
|
+
# Check it's not inside @layer by looking at the preceding text
|
|
387
|
+
before = css[: m.start()]
|
|
388
|
+
# Count open/close braces to see if we're inside something
|
|
389
|
+
open_braces = before.count("{") - before.count("}")
|
|
390
|
+
if open_braces == 0:
|
|
391
|
+
blocks[":root"] = m.group(1).strip()
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
# Extract top-level .dark { ... }
|
|
395
|
+
for m in re.finditer(r"\.dark\s*\{([^}]*)\}", css, re.DOTALL):
|
|
396
|
+
before = css[: m.start()]
|
|
397
|
+
open_braces = before.count("{") - before.count("}")
|
|
398
|
+
if open_braces == 0:
|
|
399
|
+
blocks[".dark"] = m.group(1).strip()
|
|
400
|
+
break
|
|
401
|
+
|
|
402
|
+
# Extract @layer base { ... } — may contain nested braces
|
|
403
|
+
m = re.search(r"@layer\s+base\s*\{(.*)\}", css, re.DOTALL)
|
|
404
|
+
if m:
|
|
405
|
+
# Greedy match gets the outermost @layer base block; trim to balanced braces
|
|
406
|
+
content = m.group(1)
|
|
407
|
+
# Find the balanced closing brace
|
|
408
|
+
depth = 0
|
|
409
|
+
end = len(content)
|
|
410
|
+
for i, ch in enumerate(content):
|
|
411
|
+
if ch == "{":
|
|
412
|
+
depth += 1
|
|
413
|
+
elif ch == "}":
|
|
414
|
+
if depth == 0:
|
|
415
|
+
end = i
|
|
416
|
+
break
|
|
417
|
+
depth -= 1
|
|
418
|
+
blocks["@layer"] = content[:end].strip()
|
|
419
|
+
|
|
420
|
+
return blocks
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _indent_block(content: str) -> str:
|
|
424
|
+
"""Ensure consistent 4-space indentation for all lines in a CSS block."""
|
|
425
|
+
lines = content.splitlines()
|
|
426
|
+
result: list[str] = []
|
|
427
|
+
for line in lines:
|
|
428
|
+
stripped = line.strip()
|
|
429
|
+
if stripped:
|
|
430
|
+
result.append(f" {stripped}")
|
|
431
|
+
else:
|
|
432
|
+
result.append("")
|
|
433
|
+
return "\n".join(result)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# CSS variables that shadcn presets don't include but our apps need.
|
|
437
|
+
_CUSTOM_THEME_INLINE_VARS = """ --color-destructive-foreground: var(--destructive-foreground);
|
|
438
|
+
--color-success: var(--success);
|
|
439
|
+
--color-success-foreground: var(--success-foreground);
|
|
440
|
+
--color-warning: var(--warning);
|
|
441
|
+
--color-warning-foreground: var(--warning-foreground);"""
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _ensure_custom_theme_vars(theme_block: str) -> str:
|
|
445
|
+
"""Append our custom color mappings if the preset didn't include them."""
|
|
446
|
+
for var in ("--color-destructive-foreground", "--color-success", "--color-warning"):
|
|
447
|
+
if var not in theme_block:
|
|
448
|
+
theme_block = theme_block.rstrip() + "\n" + _CUSTOM_THEME_INLINE_VARS
|
|
449
|
+
break
|
|
450
|
+
return theme_block
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _build_updated_globals(
|
|
454
|
+
old_globals: str,
|
|
455
|
+
new_blocks: dict[str, str],
|
|
456
|
+
*,
|
|
457
|
+
colors_in_target: bool = False,
|
|
458
|
+
) -> str:
|
|
459
|
+
"""Rebuild globals.css replacing @theme inline and @layer base blocks.
|
|
460
|
+
|
|
461
|
+
When *colors_in_target* is True (--target APP), :root/.dark color tokens
|
|
462
|
+
are NOT written into globals.css — they go to the theme file instead.
|
|
463
|
+
globals.css stays structural-only.
|
|
464
|
+
"""
|
|
465
|
+
lines = old_globals.splitlines(keepends=True)
|
|
466
|
+
result_parts: list[str] = []
|
|
467
|
+
i = 0
|
|
468
|
+
|
|
469
|
+
# Phase 1: Collect imports and pre-@theme content
|
|
470
|
+
while i < len(lines):
|
|
471
|
+
line = lines[i]
|
|
472
|
+
if re.match(r"\s*@theme\s+inline\s*\{", line):
|
|
473
|
+
break
|
|
474
|
+
if re.match(r"(?<!\w):root\s*\{", line.strip()):
|
|
475
|
+
break
|
|
476
|
+
result_parts.append(line)
|
|
477
|
+
i += 1
|
|
478
|
+
|
|
479
|
+
# Phase 2: Write new structural blocks
|
|
480
|
+
if "@theme" in new_blocks:
|
|
481
|
+
themed = _indent_block(new_blocks["@theme"])
|
|
482
|
+
themed = _ensure_custom_theme_vars(themed)
|
|
483
|
+
result_parts.append("@theme inline {\n")
|
|
484
|
+
result_parts.append(themed + "\n")
|
|
485
|
+
result_parts.append("}\n")
|
|
486
|
+
result_parts.append("\n")
|
|
487
|
+
|
|
488
|
+
# Only write :root/.dark into globals when colors are NOT going to a target theme file
|
|
489
|
+
if not colors_in_target:
|
|
490
|
+
if ":root" in new_blocks:
|
|
491
|
+
result_parts.append(":root {\n")
|
|
492
|
+
result_parts.append(_indent_block(new_blocks[":root"]) + "\n")
|
|
493
|
+
result_parts.append("}\n")
|
|
494
|
+
result_parts.append("\n")
|
|
495
|
+
|
|
496
|
+
if ".dark" in new_blocks:
|
|
497
|
+
result_parts.append(".dark {\n")
|
|
498
|
+
result_parts.append(_indent_block(new_blocks[".dark"]) + "\n")
|
|
499
|
+
result_parts.append("}\n")
|
|
500
|
+
result_parts.append("\n")
|
|
501
|
+
|
|
502
|
+
if "@layer" in new_blocks:
|
|
503
|
+
result_parts.append("@layer base {\n")
|
|
504
|
+
result_parts.append(_indent_block(new_blocks["@layer"]) + "\n")
|
|
505
|
+
result_parts.append("}\n")
|
|
506
|
+
|
|
507
|
+
# Phase 3: Skip over old managed blocks in original, collect custom content
|
|
508
|
+
custom_lines: list[str] = []
|
|
509
|
+
while i < len(lines):
|
|
510
|
+
line = lines[i]
|
|
511
|
+
stripped = line.strip()
|
|
512
|
+
|
|
513
|
+
# Always skip these managed block types
|
|
514
|
+
skip_patterns = [
|
|
515
|
+
r"\s*@theme\s+inline\s*\{",
|
|
516
|
+
r":root\s*\{",
|
|
517
|
+
r"\.dark\s*\{",
|
|
518
|
+
r"@layer\s+base\s*\{",
|
|
519
|
+
]
|
|
520
|
+
skipped = False
|
|
521
|
+
for pattern in skip_patterns:
|
|
522
|
+
if re.match(pattern, stripped):
|
|
523
|
+
depth = 1
|
|
524
|
+
i += 1
|
|
525
|
+
while i < len(lines) and depth > 0:
|
|
526
|
+
for ch in lines[i]:
|
|
527
|
+
if ch == "{":
|
|
528
|
+
depth += 1
|
|
529
|
+
elif ch == "}":
|
|
530
|
+
depth -= 1
|
|
531
|
+
i += 1
|
|
532
|
+
skipped = True
|
|
533
|
+
break
|
|
534
|
+
if skipped:
|
|
535
|
+
continue
|
|
536
|
+
|
|
537
|
+
# Skip @custom-variant (managed by preset)
|
|
538
|
+
if stripped.startswith("@custom-variant"):
|
|
539
|
+
i += 1
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
# Everything else is custom content — keep it
|
|
543
|
+
custom_lines.append(lines[i])
|
|
544
|
+
i += 1
|
|
545
|
+
|
|
546
|
+
# Phase 4: Append custom content (strip leading blank lines)
|
|
547
|
+
while custom_lines and custom_lines[0].strip() == "":
|
|
548
|
+
custom_lines.pop(0)
|
|
549
|
+
if custom_lines:
|
|
550
|
+
result_parts.append("\n")
|
|
551
|
+
result_parts.extend(custom_lines)
|
|
552
|
+
|
|
553
|
+
return "".join(result_parts)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _build_theme_css(root_content: str, dark_content: str, *, with_globals_import: bool = False) -> str:
|
|
557
|
+
"""Build a theme CSS file with :root and .dark blocks.
|
|
558
|
+
|
|
559
|
+
When *with_globals_import* is True, adds ``@import "../globals.css"`` at top
|
|
560
|
+
so the theme file can be the single import in an app's index.css.
|
|
561
|
+
"""
|
|
562
|
+
parts: list[str] = []
|
|
563
|
+
if with_globals_import:
|
|
564
|
+
parts.append('@import "../globals.css";\n\n')
|
|
565
|
+
parts.extend(
|
|
566
|
+
[
|
|
567
|
+
":root {\n",
|
|
568
|
+
_indent_block(root_content) + "\n",
|
|
569
|
+
"}\n",
|
|
570
|
+
"\n",
|
|
571
|
+
".dark {\n",
|
|
572
|
+
_indent_block(dark_content) + "\n",
|
|
573
|
+
"}\n",
|
|
574
|
+
]
|
|
575
|
+
)
|
|
576
|
+
return "".join(parts)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.command()
|
|
580
|
+
def preset(
|
|
581
|
+
ctx: typer.Context,
|
|
582
|
+
name: Annotated[str, typer.Argument(help="Preset ID from ui.shadcn.com/themes (e.g. bIkeymG)")],
|
|
583
|
+
reinstall: Annotated[
|
|
584
|
+
bool, typer.Option("--reinstall", help="Re-install existing components with new style")
|
|
585
|
+
] = False,
|
|
586
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would change without writing")] = False,
|
|
587
|
+
target: Annotated[
|
|
588
|
+
str,
|
|
589
|
+
typer.Option("--target", "-t", help="Target: 'base' for base-neutral.css or APP_NAME for theme file"),
|
|
590
|
+
] = "base",
|
|
591
|
+
) -> None:
|
|
592
|
+
"""Apply a shadcn theme preset — extract CSS vars into the monorepo theme system.
|
|
593
|
+
|
|
594
|
+
Creates a temp Vite project, runs shadcn init --preset, extracts the
|
|
595
|
+
generated CSS variables and @theme inline block, then updates:
|
|
596
|
+
|
|
597
|
+
\b
|
|
598
|
+
- globals.css — @theme inline, :root, .dark, @layer base
|
|
599
|
+
- base-neutral.css (or themes/{app}.css) — :root and .dark color vars
|
|
600
|
+
|
|
601
|
+
Get preset IDs from https://ui.shadcn.com/themes — click a theme,
|
|
602
|
+
copy the preset code from the URL or init command.
|
|
603
|
+
|
|
604
|
+
Examples:
|
|
605
|
+
kctl-react ui preset b1D0dv72 # Apply to base-neutral.css
|
|
606
|
+
kctl-react ui preset b1D0dv72 --target sfa # Apply to themes/sfa.css
|
|
607
|
+
kctl-react ui preset b1D0dv72 --dry-run # Preview changes
|
|
608
|
+
kctl-react ui preset b1D0dv72 --reinstall # Apply + reinstall components
|
|
609
|
+
"""
|
|
610
|
+
actx: AppContext = ctx.obj
|
|
611
|
+
out = actx.output
|
|
612
|
+
|
|
613
|
+
# Validate target
|
|
614
|
+
if target != "base" and target not in _VALID_APPS:
|
|
615
|
+
out.error(f"Invalid target '{target}'. Use 'base' or an app name: {', '.join(sorted(_VALID_APPS))}")
|
|
616
|
+
raise typer.Exit(1)
|
|
617
|
+
|
|
618
|
+
tc_src = actx.project_root / "packages" / "tailwind-config" / "src"
|
|
619
|
+
globals_path = tc_src / "globals.css"
|
|
620
|
+
target_path = tc_src / "base-neutral.css" if target == "base" else tc_src / "themes" / f"{target}.css"
|
|
621
|
+
|
|
622
|
+
ui_components_json = actx.project_root / "packages" / "ui" / "components.json"
|
|
623
|
+
|
|
624
|
+
# Step 1: Create temp directory with minimal Vite scaffolding
|
|
625
|
+
tmp_dir = tempfile.mkdtemp(prefix="kctl-preset-")
|
|
626
|
+
try:
|
|
627
|
+
out.info(f"Creating temp Vite project in {tmp_dir}...")
|
|
628
|
+
|
|
629
|
+
# package.json
|
|
630
|
+
pkg = {
|
|
631
|
+
"name": "preset-tmp",
|
|
632
|
+
"private": True,
|
|
633
|
+
"dependencies": {
|
|
634
|
+
"vite": "*",
|
|
635
|
+
"react": "*",
|
|
636
|
+
"react-dom": "*",
|
|
637
|
+
"tailwindcss": "*",
|
|
638
|
+
"@tailwindcss/vite": "*",
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
Path(tmp_dir, "package.json").write_text(json.dumps(pkg, indent=2))
|
|
642
|
+
|
|
643
|
+
# vite.config.js
|
|
644
|
+
Path(tmp_dir, "vite.config.js").write_text(
|
|
645
|
+
'import { defineConfig } from "vite"\n'
|
|
646
|
+
'import tailwindcss from "@tailwindcss/vite"\n'
|
|
647
|
+
"export default defineConfig({ plugins: [tailwindcss()] })\n"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# tsconfig.json
|
|
651
|
+
Path(tmp_dir, "tsconfig.json").write_text(
|
|
652
|
+
json.dumps(
|
|
653
|
+
{
|
|
654
|
+
"compilerOptions": {
|
|
655
|
+
"baseUrl": ".",
|
|
656
|
+
"paths": {"@/*": ["./src/*"]},
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
indent=2,
|
|
660
|
+
)
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# components.json — copy from packages/ui
|
|
664
|
+
if ui_components_json.exists():
|
|
665
|
+
shutil.copy2(ui_components_json, Path(tmp_dir, "components.json"))
|
|
666
|
+
else:
|
|
667
|
+
Path(tmp_dir, "components.json").write_text(
|
|
668
|
+
json.dumps(
|
|
669
|
+
{
|
|
670
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
671
|
+
"style": "new-york",
|
|
672
|
+
"rsc": False,
|
|
673
|
+
"tsx": True,
|
|
674
|
+
"aliases": {"components": "src/components", "utils": "src/lib/utils"},
|
|
675
|
+
},
|
|
676
|
+
indent=2,
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# src/index.css
|
|
681
|
+
src_dir = Path(tmp_dir, "src")
|
|
682
|
+
src_dir.mkdir()
|
|
683
|
+
(src_dir / "index.css").write_text('@import "tailwindcss";\n')
|
|
684
|
+
|
|
685
|
+
# Step 2: Install deps and run preset
|
|
686
|
+
out.info("Installing temp dependencies (npm)...")
|
|
687
|
+
try:
|
|
688
|
+
run(["npm", "install", "--silent"], cwd=Path(tmp_dir), capture=True, timeout=120)
|
|
689
|
+
except CommandError as exc:
|
|
690
|
+
out.error(f"npm install failed: {exc}")
|
|
691
|
+
raise typer.Exit(1) from exc
|
|
692
|
+
|
|
693
|
+
out.info(f"Running shadcn init --preset {name}...")
|
|
694
|
+
try:
|
|
695
|
+
run(
|
|
696
|
+
["npx", "shadcn@latest", "init", "--preset", name, "--force", "--no-reinstall", "-y"],
|
|
697
|
+
cwd=Path(tmp_dir),
|
|
698
|
+
capture=True,
|
|
699
|
+
timeout=120,
|
|
700
|
+
)
|
|
701
|
+
except CommandError as exc:
|
|
702
|
+
out.error(f"shadcn init --preset failed: {exc}")
|
|
703
|
+
raise typer.Exit(1) from exc
|
|
704
|
+
|
|
705
|
+
# Step 3: Extract CSS from generated index.css
|
|
706
|
+
generated_css_path = src_dir / "index.css"
|
|
707
|
+
if not generated_css_path.exists():
|
|
708
|
+
out.error("shadcn did not generate src/index.css")
|
|
709
|
+
raise typer.Exit(1)
|
|
710
|
+
|
|
711
|
+
generated_css = generated_css_path.read_text()
|
|
712
|
+
blocks = _extract_css_blocks(generated_css)
|
|
713
|
+
|
|
714
|
+
if ":root" not in blocks:
|
|
715
|
+
out.error("Could not extract :root block from generated CSS")
|
|
716
|
+
raise typer.Exit(1)
|
|
717
|
+
|
|
718
|
+
# Step 4: Extract style from generated components.json
|
|
719
|
+
new_style: str | None = None
|
|
720
|
+
generated_cj = Path(tmp_dir, "components.json")
|
|
721
|
+
if generated_cj.exists():
|
|
722
|
+
try:
|
|
723
|
+
cj_data = json.loads(generated_cj.read_text())
|
|
724
|
+
new_style = cj_data.get("style")
|
|
725
|
+
except (json.JSONDecodeError, OSError):
|
|
726
|
+
pass
|
|
727
|
+
|
|
728
|
+
# ── Summary / Dry-run ───────────────────────────────────────
|
|
729
|
+
out.info("Extracted CSS blocks:")
|
|
730
|
+
for key in ("@theme", ":root", ".dark", "@layer"):
|
|
731
|
+
if key in blocks:
|
|
732
|
+
line_count = blocks[key].count("\n") + 1
|
|
733
|
+
out.text(f" {key}: {line_count} lines")
|
|
734
|
+
|
|
735
|
+
if new_style:
|
|
736
|
+
out.text(f" style: {new_style}")
|
|
737
|
+
|
|
738
|
+
if dry_run:
|
|
739
|
+
out.info("[dry-run] Would update:")
|
|
740
|
+
if globals_path.exists():
|
|
741
|
+
out.text(f" {globals_path.relative_to(actx.project_root)}")
|
|
742
|
+
out.text(f" {target_path.relative_to(actx.project_root) if target_path.exists() else target_path.name}")
|
|
743
|
+
if new_style and ui_components_json.exists():
|
|
744
|
+
out.text(f" {ui_components_json.relative_to(actx.project_root)} (style -> {new_style})")
|
|
745
|
+
out.success("[dry-run] No files modified")
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
# ── Step 5: Update globals.css ──────────────────────────────
|
|
749
|
+
changes: list[str] = []
|
|
750
|
+
colors_in_target = target != "base"
|
|
751
|
+
|
|
752
|
+
if globals_path.exists() and blocks:
|
|
753
|
+
old_globals = globals_path.read_text()
|
|
754
|
+
new_globals = _build_updated_globals(old_globals, blocks, colors_in_target=colors_in_target)
|
|
755
|
+
globals_path.write_text(new_globals)
|
|
756
|
+
changes.append(str(globals_path.relative_to(actx.project_root)))
|
|
757
|
+
|
|
758
|
+
# ── Step 6: Update target theme file ────────────────────────
|
|
759
|
+
root_content = blocks.get(":root", "")
|
|
760
|
+
dark_content = blocks.get(".dark", "")
|
|
761
|
+
if root_content:
|
|
762
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
763
|
+
# App themes get @import "../globals.css"; base-neutral does not
|
|
764
|
+
target_path.write_text(_build_theme_css(root_content, dark_content, with_globals_import=colors_in_target))
|
|
765
|
+
changes.append(str(target_path.relative_to(actx.project_root)))
|
|
766
|
+
|
|
767
|
+
# ── Step 7: Update components.json style ────────────────────
|
|
768
|
+
if new_style and ui_components_json.exists():
|
|
769
|
+
try:
|
|
770
|
+
cj = json.loads(ui_components_json.read_text())
|
|
771
|
+
old_style = cj.get("style", "")
|
|
772
|
+
if old_style != new_style:
|
|
773
|
+
cj["style"] = new_style
|
|
774
|
+
ui_components_json.write_text(json.dumps(cj, indent=2) + "\n")
|
|
775
|
+
changes.append(
|
|
776
|
+
f"{ui_components_json.relative_to(actx.project_root)} (style: {old_style} -> {new_style})"
|
|
777
|
+
)
|
|
778
|
+
except (json.JSONDecodeError, OSError):
|
|
779
|
+
out.warn("Could not update components.json")
|
|
780
|
+
|
|
781
|
+
for c in changes:
|
|
782
|
+
out.text(f" Updated: {c}")
|
|
783
|
+
out.success(f"Preset '{name}' applied successfully")
|
|
784
|
+
|
|
785
|
+
finally:
|
|
786
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
787
|
+
|
|
788
|
+
# ── Step 8: Optional reinstall ──────────────────────────────────
|
|
789
|
+
if reinstall:
|
|
790
|
+
out.info("Re-installing components with new style...")
|
|
791
|
+
try:
|
|
792
|
+
run(
|
|
793
|
+
["npx", "shadcn@latest", "init", "--force", "--reinstall", "-c", "packages/ui"],
|
|
794
|
+
cwd=actx.project_root,
|
|
795
|
+
capture=False,
|
|
796
|
+
)
|
|
797
|
+
out.success("Components reinstalled with new style")
|
|
798
|
+
except CommandError as exc:
|
|
799
|
+
out.warn(f"Reinstall failed (components may need manual update): {exc}")
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
@app.command()
|
|
803
|
+
def info(ctx: typer.Context) -> None:
|
|
804
|
+
"""Show shadcn project configuration from packages/ui."""
|
|
805
|
+
actx: AppContext = ctx.obj
|
|
806
|
+
out = actx.output
|
|
807
|
+
cmd = ["npx", "shadcn@latest", "info", "-c", "packages/ui"]
|
|
808
|
+
try:
|
|
809
|
+
run(cmd, cwd=actx.project_root, capture=False)
|
|
810
|
+
except CommandError as exc:
|
|
811
|
+
out.error(str(exc))
|
|
812
|
+
raise typer.Exit(1) from exc
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@app.command()
|
|
816
|
+
def installed(ctx: typer.Context) -> None:
|
|
817
|
+
"""List all installed shadcn components in @kodemeio/ui."""
|
|
818
|
+
actx: AppContext = ctx.obj
|
|
819
|
+
out = actx.output
|
|
820
|
+
components_dir = actx.project_root / "packages" / "ui" / "src" / "components"
|
|
821
|
+
if not components_dir.exists():
|
|
822
|
+
out.warn("No components directory found at packages/ui/src/components")
|
|
823
|
+
return
|
|
824
|
+
files = sorted(components_dir.glob("*.tsx"))
|
|
825
|
+
if not files:
|
|
826
|
+
out.warn("No .tsx component files found")
|
|
827
|
+
return
|
|
828
|
+
rows: list[list[str]] = []
|
|
829
|
+
json_data: list[dict[str, str]] = []
|
|
830
|
+
for f in files:
|
|
831
|
+
name = f.stem
|
|
832
|
+
comp_type = "custom" if name in _CUSTOM_COMPONENTS else "shadcn"
|
|
833
|
+
rel_path = str(f.relative_to(actx.project_root))
|
|
834
|
+
rows.append([name, comp_type, rel_path])
|
|
835
|
+
json_data.append({"component": name, "type": comp_type, "path": rel_path})
|
|
836
|
+
out.table(
|
|
837
|
+
f"Installed Components ({len(files)})",
|
|
838
|
+
[("Component", "cyan"), ("Type", "yellow"), ("Path", "dim")],
|
|
839
|
+
rows,
|
|
840
|
+
data_for_json=json_data,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@app.command()
|
|
845
|
+
def unused(ctx: typer.Context) -> None:
|
|
846
|
+
"""Find installed components not used by any app."""
|
|
847
|
+
actx: AppContext = ctx.obj
|
|
848
|
+
out = actx.output
|
|
849
|
+
components_dir = actx.project_root / "packages" / "ui" / "src" / "components"
|
|
850
|
+
if not components_dir.exists():
|
|
851
|
+
out.warn("No components directory found at packages/ui/src/components")
|
|
852
|
+
return
|
|
853
|
+
comp_names = {f.stem for f in components_dir.glob("*.tsx")}
|
|
854
|
+
if not comp_names:
|
|
855
|
+
out.warn("No .tsx component files found")
|
|
856
|
+
return
|
|
857
|
+
# Scan all apps for imports from @kodemeio/ui
|
|
858
|
+
usage: dict[str, set[str]] = {name: set() for name in comp_names}
|
|
859
|
+
for app_name in actx.app_names:
|
|
860
|
+
app_dir = actx.get_app_dir(app_name)
|
|
861
|
+
src_dir = app_dir / "src"
|
|
862
|
+
if not src_dir.is_dir():
|
|
863
|
+
continue
|
|
864
|
+
app_name = app_dir.name
|
|
865
|
+
for tsx_file in src_dir.rglob("*.tsx"):
|
|
866
|
+
try:
|
|
867
|
+
content = tsx_file.read_text(errors="ignore")
|
|
868
|
+
except OSError:
|
|
869
|
+
continue
|
|
870
|
+
for comp in comp_names:
|
|
871
|
+
# Check if the component name (PascalCase or kebab-case) appears
|
|
872
|
+
# in an import from @kodemeio/ui
|
|
873
|
+
pascal = comp.replace("-", " ").title().replace(" ", "")
|
|
874
|
+
if pascal in content or f"/{comp}" in content:
|
|
875
|
+
usage[comp].add(app_name)
|
|
876
|
+
rows: list[list[str]] = []
|
|
877
|
+
json_data: list[dict] = []
|
|
878
|
+
for comp in sorted(comp_names):
|
|
879
|
+
apps_using = usage[comp]
|
|
880
|
+
status = "used" if apps_using else "unused"
|
|
881
|
+
count = len(apps_using)
|
|
882
|
+
rows.append([comp, status, str(count)])
|
|
883
|
+
json_data.append({"component": comp, "status": status, "used_by": count})
|
|
884
|
+
out.table(
|
|
885
|
+
f"Component Usage ({len(comp_names)} total)",
|
|
886
|
+
[("Component", "cyan"), ("Status", "green"), ("Used By", "")],
|
|
887
|
+
rows,
|
|
888
|
+
data_for_json=json_data,
|
|
889
|
+
)
|