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,368 @@
|
|
|
1
|
+
"""Configuration management commands.
|
|
2
|
+
|
|
3
|
+
Initialize, view, and manage CLI profiles and connection settings.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_react.core.callbacks import AppContext
|
|
14
|
+
from kctl_react.core.config import (
|
|
15
|
+
CONFIG_FILE,
|
|
16
|
+
SERVICE_KEY,
|
|
17
|
+
ServiceConfig,
|
|
18
|
+
get_all_services_in_profile,
|
|
19
|
+
get_default_profile,
|
|
20
|
+
get_profile_names,
|
|
21
|
+
get_service_config,
|
|
22
|
+
load_raw_config,
|
|
23
|
+
remove_profile,
|
|
24
|
+
resolve_active_profile_name,
|
|
25
|
+
resolve_project_root,
|
|
26
|
+
save_raw_config,
|
|
27
|
+
set_default_profile,
|
|
28
|
+
set_service_config,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(help="Manage CLI configuration and profiles.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def init(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
root: Annotated[str | None, typer.Option("--root", help="Monorepo root directory.")] = None,
|
|
38
|
+
api_url: Annotated[str | None, typer.Option("--api-url", help="Backend API URL.")] = None,
|
|
39
|
+
name: Annotated[str | None, typer.Option("--name", "-n", help="Profile name.")] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initialize CLI configuration (interactive if no flags given)."""
|
|
42
|
+
actx: AppContext = ctx.obj
|
|
43
|
+
out = actx.output
|
|
44
|
+
|
|
45
|
+
profile_name = name or typer.prompt("Profile name", default="default")
|
|
46
|
+
project_root = root or str(resolve_project_root())
|
|
47
|
+
backend_url = api_url or typer.prompt("Backend API URL", default="https://api.kodeme.io")
|
|
48
|
+
|
|
49
|
+
# Verify project root
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
root_path = Path(project_root)
|
|
53
|
+
if (root_path / "turbo.json").exists():
|
|
54
|
+
out.success(f"Monorepo detected at {project_root}")
|
|
55
|
+
else:
|
|
56
|
+
out.warn(f"No turbo.json found at {project_root}")
|
|
57
|
+
if not typer.confirm("Save configuration anyway?", default=False):
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
svc = ServiceConfig(project_root=project_root, api_url=backend_url)
|
|
61
|
+
set_service_config(profile_name, svc)
|
|
62
|
+
|
|
63
|
+
if len(get_profile_names()) <= 1:
|
|
64
|
+
set_default_profile(profile_name)
|
|
65
|
+
|
|
66
|
+
out.success(f"Configuration saved to {CONFIG_FILE}")
|
|
67
|
+
out.kv("Profile", profile_name)
|
|
68
|
+
out.kv("Service", SERVICE_KEY)
|
|
69
|
+
out.kv("Project root", project_root)
|
|
70
|
+
out.kv("API URL", backend_url)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def add(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
name: Annotated[str, typer.Argument(help="Profile name (e.g. abcfood, staging)")],
|
|
77
|
+
root: Annotated[str | None, typer.Option("--root", help="Monorepo root directory.")] = None,
|
|
78
|
+
api_url: Annotated[str | None, typer.Option("--api-url", help="Backend API URL.")] = None,
|
|
79
|
+
odoo_url: Annotated[str | None, typer.Option("--odoo-url", help="Odoo URL.")] = None,
|
|
80
|
+
odoo_db: Annotated[str | None, typer.Option("--odoo-db", help="Odoo database name.")] = None,
|
|
81
|
+
set_default: Annotated[bool, typer.Option("--default", help="Set as default profile.")] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Add or update a profile's React monorepo connection."""
|
|
84
|
+
actx: AppContext = ctx.obj
|
|
85
|
+
out = actx.output
|
|
86
|
+
|
|
87
|
+
project_root = root or typer.prompt("Monorepo root", default=str(resolve_project_root()))
|
|
88
|
+
backend_url = api_url or typer.prompt("Backend API URL", default="https://api.kodeme.io")
|
|
89
|
+
|
|
90
|
+
existing = get_service_config(name)
|
|
91
|
+
if existing.project_root and not typer.confirm(f"Profile '{name}' already has {SERVICE_KEY} config. Overwrite?"):
|
|
92
|
+
raise typer.Exit(0)
|
|
93
|
+
|
|
94
|
+
svc = ServiceConfig(project_root=project_root, api_url=backend_url)
|
|
95
|
+
if odoo_url:
|
|
96
|
+
svc.odoo_url = odoo_url
|
|
97
|
+
if odoo_db:
|
|
98
|
+
svc.odoo_db = odoo_db
|
|
99
|
+
|
|
100
|
+
set_service_config(name, svc)
|
|
101
|
+
|
|
102
|
+
if set_default or len(get_profile_names()) == 1:
|
|
103
|
+
set_default_profile(name)
|
|
104
|
+
|
|
105
|
+
out.success(f"Profile '{name}' -> {SERVICE_KEY} configured")
|
|
106
|
+
out.kv("Root", project_root)
|
|
107
|
+
out.kv("API URL", backend_url)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def use(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
name: Annotated[str, typer.Argument(help="Profile name to switch to")],
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Switch the default profile."""
|
|
116
|
+
actx: AppContext = ctx.obj
|
|
117
|
+
out = actx.output
|
|
118
|
+
|
|
119
|
+
profiles = get_profile_names()
|
|
120
|
+
if name not in profiles:
|
|
121
|
+
out.error(f"Profile '{name}' not found")
|
|
122
|
+
out.info(f"Available: {', '.join(profiles)}")
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
|
|
125
|
+
old_default = get_default_profile()
|
|
126
|
+
set_default_profile(name)
|
|
127
|
+
|
|
128
|
+
svc = get_service_config(name)
|
|
129
|
+
out.success(f"Switched to '{name}' (root: {svc.project_root or 'auto-detect'})")
|
|
130
|
+
out.info(f"Previous default: {old_default}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def remove(
|
|
135
|
+
ctx: typer.Context,
|
|
136
|
+
name: Annotated[str, typer.Argument(help="Profile name to remove")],
|
|
137
|
+
force: Annotated[bool, typer.Option("--force", help="Skip confirmation")] = False,
|
|
138
|
+
service_only: Annotated[bool, typer.Option("--service-only", help="Only remove react config")] = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Remove a profile or just its React config."""
|
|
141
|
+
actx: AppContext = ctx.obj
|
|
142
|
+
out = actx.output
|
|
143
|
+
|
|
144
|
+
profiles = get_profile_names()
|
|
145
|
+
if name not in profiles:
|
|
146
|
+
out.error(f"Profile '{name}' not found")
|
|
147
|
+
raise typer.Exit(1)
|
|
148
|
+
|
|
149
|
+
if service_only:
|
|
150
|
+
if not force and not typer.confirm(f"Remove {SERVICE_KEY} config from '{name}'?"):
|
|
151
|
+
raise typer.Exit(0)
|
|
152
|
+
data = load_raw_config()
|
|
153
|
+
profile = data.get("profiles", {}).get(name, {})
|
|
154
|
+
profile.pop(SERVICE_KEY, None)
|
|
155
|
+
save_raw_config(data)
|
|
156
|
+
out.success(f"Removed {SERVICE_KEY} config from profile '{name}'")
|
|
157
|
+
else:
|
|
158
|
+
if not force:
|
|
159
|
+
services = get_all_services_in_profile(name)
|
|
160
|
+
svc_list = ", ".join(services.keys())
|
|
161
|
+
if not typer.confirm(f"Remove entire profile '{name}' (services: {svc_list})?"):
|
|
162
|
+
raise typer.Exit(0)
|
|
163
|
+
remove_profile(name)
|
|
164
|
+
out.success(f"Profile '{name}' removed")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command()
|
|
168
|
+
def show(ctx: typer.Context) -> None:
|
|
169
|
+
"""Show full configuration."""
|
|
170
|
+
actx: AppContext = ctx.obj
|
|
171
|
+
out = actx.output
|
|
172
|
+
|
|
173
|
+
default = get_default_profile()
|
|
174
|
+
profiles = get_profile_names()
|
|
175
|
+
|
|
176
|
+
if out.json_mode:
|
|
177
|
+
out.raw_json(load_raw_config())
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = []
|
|
181
|
+
|
|
182
|
+
sections.append(
|
|
183
|
+
(
|
|
184
|
+
"General",
|
|
185
|
+
[
|
|
186
|
+
("Config file", str(CONFIG_FILE)),
|
|
187
|
+
("Default profile", default),
|
|
188
|
+
("Total profiles", str(len(profiles))),
|
|
189
|
+
("This CLI", f"kctl-react -> service key: {SERVICE_KEY}"),
|
|
190
|
+
],
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
for pname in profiles:
|
|
195
|
+
marker = " [green](default)[/green]" if pname == default else ""
|
|
196
|
+
services = get_all_services_in_profile(pname)
|
|
197
|
+
|
|
198
|
+
kvs: list[tuple[str, str]] = []
|
|
199
|
+
for svc_name, svc_data in services.items():
|
|
200
|
+
if not isinstance(svc_data, dict):
|
|
201
|
+
continue
|
|
202
|
+
indicator = "[green]●[/green]" if svc_name == SERVICE_KEY else "[dim]○[/dim]"
|
|
203
|
+
svc_root = svc_data.get("project_root", svc_data.get("url", ""))
|
|
204
|
+
kvs.append((f"{indicator} {svc_name}", svc_root))
|
|
205
|
+
|
|
206
|
+
if not kvs:
|
|
207
|
+
kvs.append(("(empty)", "no services configured"))
|
|
208
|
+
|
|
209
|
+
sections.append((f"Profile: {pname}{marker}", kvs))
|
|
210
|
+
|
|
211
|
+
out.detail("Configuration", sections)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command("set")
|
|
215
|
+
def set_(
|
|
216
|
+
ctx: typer.Context,
|
|
217
|
+
key: Annotated[str, typer.Argument(help="Config key (e.g. project_root, api_url, odoo_url, odoo_db)")],
|
|
218
|
+
value: Annotated[str, typer.Argument(help="Value to set")],
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Set a configuration value."""
|
|
221
|
+
actx: AppContext = ctx.obj
|
|
222
|
+
out = actx.output
|
|
223
|
+
|
|
224
|
+
if key == "default_profile":
|
|
225
|
+
set_default_profile(value)
|
|
226
|
+
out.success(f"Default profile set to: {value}")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
pname = resolve_active_profile_name(actx.profile)
|
|
230
|
+
svc = get_service_config(pname)
|
|
231
|
+
|
|
232
|
+
valid_fields = {"project_root", "api_url", "odoo_url", "odoo_db"}
|
|
233
|
+
if key not in valid_fields:
|
|
234
|
+
out.error(f"Unknown key: {key}")
|
|
235
|
+
out.info(f"Valid keys: {', '.join(sorted(valid_fields))}, default_profile")
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
setattr(svc, key, value)
|
|
239
|
+
set_service_config(pname, svc)
|
|
240
|
+
out.success(f"[{pname}] {SERVICE_KEY}.{key} = {value}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command()
|
|
244
|
+
def profiles(ctx: typer.Context) -> None:
|
|
245
|
+
"""List all profiles."""
|
|
246
|
+
actx: AppContext = ctx.obj
|
|
247
|
+
out = actx.output
|
|
248
|
+
|
|
249
|
+
profile_names = get_profile_names()
|
|
250
|
+
if not profile_names:
|
|
251
|
+
out.warn("No profiles configured. Run: kctl-react config init")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
active = resolve_active_profile_name(actx.profile)
|
|
255
|
+
default = get_default_profile()
|
|
256
|
+
|
|
257
|
+
rows: list[list[str]] = []
|
|
258
|
+
json_data: list[dict] = []
|
|
259
|
+
|
|
260
|
+
for pname in profile_names:
|
|
261
|
+
svc = get_service_config(pname)
|
|
262
|
+
all_services = get_all_services_in_profile(pname)
|
|
263
|
+
other_services = [s for s in all_services if s != SERVICE_KEY]
|
|
264
|
+
|
|
265
|
+
is_active = pname == active
|
|
266
|
+
status_marker = "[green]active[/green]" if is_active else ("default" if pname == default else "")
|
|
267
|
+
|
|
268
|
+
other_str = ", ".join(other_services) if other_services else "[dim]-[/dim]"
|
|
269
|
+
|
|
270
|
+
rows.append([pname, svc.project_root or "-", svc.api_url or "-", other_str, status_marker])
|
|
271
|
+
json_data.append(
|
|
272
|
+
{
|
|
273
|
+
"name": pname,
|
|
274
|
+
"project_root": svc.project_root,
|
|
275
|
+
"api_url": svc.api_url,
|
|
276
|
+
"other_services": other_services,
|
|
277
|
+
"active": is_active,
|
|
278
|
+
"default": pname == default,
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
out.table(
|
|
283
|
+
"Profiles",
|
|
284
|
+
[("Name", "cyan"), ("Project Root", ""), ("API URL", ""), ("Other Services", "dim"), ("", "green")],
|
|
285
|
+
rows,
|
|
286
|
+
data_for_json=json_data,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.command()
|
|
291
|
+
def current(ctx: typer.Context) -> None:
|
|
292
|
+
"""Show the active profile and project root."""
|
|
293
|
+
actx: AppContext = ctx.obj
|
|
294
|
+
out = actx.output
|
|
295
|
+
|
|
296
|
+
active = resolve_active_profile_name(actx.profile)
|
|
297
|
+
svc = get_service_config(active)
|
|
298
|
+
root = resolve_project_root(profile_name=actx.profile, root_override=actx.root_override)
|
|
299
|
+
|
|
300
|
+
source = "config default"
|
|
301
|
+
if actx.profile:
|
|
302
|
+
source = "--profile flag"
|
|
303
|
+
elif os.environ.get("KCTL_REACT_PROFILE"):
|
|
304
|
+
source = "KCTL_REACT_PROFILE env var"
|
|
305
|
+
|
|
306
|
+
turbo_exists = (root / "turbo.json").exists()
|
|
307
|
+
|
|
308
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = [
|
|
309
|
+
(
|
|
310
|
+
"Active Connection",
|
|
311
|
+
[
|
|
312
|
+
("Profile", active),
|
|
313
|
+
("Service", SERVICE_KEY),
|
|
314
|
+
("Source", source),
|
|
315
|
+
("Project root", str(root)),
|
|
316
|
+
("Monorepo detected", "[green]Yes[/green]" if turbo_exists else "[red]No[/red]"),
|
|
317
|
+
("API URL", svc.api_url or "[dim]not set[/dim]"),
|
|
318
|
+
],
|
|
319
|
+
),
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
if svc.odoo_url or svc.odoo_db:
|
|
323
|
+
sections.append(
|
|
324
|
+
(
|
|
325
|
+
"Odoo Settings",
|
|
326
|
+
[
|
|
327
|
+
("Odoo URL", svc.odoo_url or "[dim]not set[/dim]"),
|
|
328
|
+
("Odoo DB", svc.odoo_db or "[dim]not set[/dim]"),
|
|
329
|
+
],
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
all_services = get_all_services_in_profile(active)
|
|
334
|
+
other = {k: v for k, v in all_services.items() if k != SERVICE_KEY and isinstance(v, dict)}
|
|
335
|
+
if other:
|
|
336
|
+
sections.append(
|
|
337
|
+
(
|
|
338
|
+
"Other Services in Profile",
|
|
339
|
+
[(svc_name, v.get("url", v.get("project_root", "(no url)"))) for svc_name, v in other.items()],
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
out.detail(
|
|
344
|
+
"Current Profile",
|
|
345
|
+
sections,
|
|
346
|
+
data_for_json={
|
|
347
|
+
"profile": active,
|
|
348
|
+
"service": SERVICE_KEY,
|
|
349
|
+
"source": source,
|
|
350
|
+
"project_root": str(root),
|
|
351
|
+
"monorepo_detected": turbo_exists,
|
|
352
|
+
"api_url": svc.api_url,
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.command()
|
|
358
|
+
def test(ctx: typer.Context) -> None:
|
|
359
|
+
"""Verify configuration is valid."""
|
|
360
|
+
actx: AppContext = ctx.obj
|
|
361
|
+
out = actx.output
|
|
362
|
+
active = resolve_active_profile_name(actx.profile)
|
|
363
|
+
out.info(f"Testing profile '{active}' \u2192 {SERVICE_KEY}")
|
|
364
|
+
svc = get_service_config(active)
|
|
365
|
+
if not svc or not svc.project_root:
|
|
366
|
+
out.error("No configuration found. Run: kctl-react config init")
|
|
367
|
+
raise typer.Exit(1)
|
|
368
|
+
out.success(f"Configuration valid for profile '{active}'")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Top-level dashboard command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_react.core.callbacks import AppContext
|
|
13
|
+
from kctl_react.core.discovery import get_app_dir
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Monorepo overview dashboard.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fetch_dashboard(actx: AppContext) -> dict:
|
|
19
|
+
"""Collect all dashboard data."""
|
|
20
|
+
root = actx.project_root
|
|
21
|
+
|
|
22
|
+
apps_found = sum(1 for a in actx.app_names if (get_app_dir(root, a)).is_dir())
|
|
23
|
+
pkgs_found = sum(1 for p in actx.packages if (root / "packages" / p / "package.json").exists())
|
|
24
|
+
|
|
25
|
+
total_tests = 0
|
|
26
|
+
for name in actx.app_names:
|
|
27
|
+
src = get_app_dir(root, name) / "src"
|
|
28
|
+
if src.is_dir():
|
|
29
|
+
total_tests += len(list(src.rglob("*.test.ts"))) + len(list(src.rglob("*.test.tsx")))
|
|
30
|
+
|
|
31
|
+
built_apps = sum(
|
|
32
|
+
1
|
|
33
|
+
for a in actx.app_names
|
|
34
|
+
if (get_app_dir(root, a) / "dist").is_dir() or (get_app_dir(root, a) / ".next").is_dir()
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
running = 0
|
|
38
|
+
app_running: dict[str, bool] = {}
|
|
39
|
+
for name in actx.app_names:
|
|
40
|
+
port = actx.apps[name]["port"]
|
|
41
|
+
try:
|
|
42
|
+
r = httpx.get(f"http://localhost:{port}", timeout=1, follow_redirects=True)
|
|
43
|
+
is_up = r.status_code < 500
|
|
44
|
+
except (httpx.HTTPError, Exception):
|
|
45
|
+
is_up = False
|
|
46
|
+
app_running[name] = is_up
|
|
47
|
+
if is_up:
|
|
48
|
+
running += 1
|
|
49
|
+
|
|
50
|
+
has_node_modules = (root / "node_modules").is_dir()
|
|
51
|
+
has_lockfile = (root / "pnpm-lock.yaml").exists()
|
|
52
|
+
|
|
53
|
+
git_branch = ""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["git", "branch", "--show-current"],
|
|
57
|
+
cwd=root,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=5,
|
|
61
|
+
)
|
|
62
|
+
git_branch = result.stdout.strip()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"apps_total": len(actx.app_names),
|
|
68
|
+
"apps_found": apps_found,
|
|
69
|
+
"packages_total": len(actx.packages),
|
|
70
|
+
"packages_found": pkgs_found,
|
|
71
|
+
"test_files": total_tests,
|
|
72
|
+
"built_apps": built_apps,
|
|
73
|
+
"running_apps": running,
|
|
74
|
+
"app_running": app_running,
|
|
75
|
+
"has_node_modules": has_node_modules,
|
|
76
|
+
"has_lockfile": has_lockfile,
|
|
77
|
+
"git_branch": git_branch,
|
|
78
|
+
"project_root": str(root),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _display_dashboard(actx: AppContext, data: dict) -> None:
|
|
83
|
+
"""Render dashboard output."""
|
|
84
|
+
out = actx.output
|
|
85
|
+
|
|
86
|
+
if out.json_mode:
|
|
87
|
+
out.raw_json(data)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = []
|
|
91
|
+
|
|
92
|
+
sections.append(
|
|
93
|
+
(
|
|
94
|
+
"Project",
|
|
95
|
+
[
|
|
96
|
+
("Root", data["project_root"]),
|
|
97
|
+
("Git branch", data["git_branch"] or "[dim]unknown[/dim]"),
|
|
98
|
+
("Node modules", "[green]installed[/green]" if data["has_node_modules"] else "[red]missing[/red]"),
|
|
99
|
+
("Lock file", "[green]OK[/green]" if data["has_lockfile"] else "[red]missing[/red]"),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
sections.append(
|
|
105
|
+
(
|
|
106
|
+
"Resources",
|
|
107
|
+
[
|
|
108
|
+
("Apps", f"{data['apps_found']}/{data['apps_total']}"),
|
|
109
|
+
("Shared packages", f"{data['packages_found']}/{data['packages_total']}"),
|
|
110
|
+
("Test files", str(data["test_files"])),
|
|
111
|
+
("Built apps", f"{data['built_apps']}/{data['apps_total']}"),
|
|
112
|
+
("Running dev servers", f"{data['running_apps']}/{data['apps_total']}"),
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
app_running = data.get("app_running", {})
|
|
118
|
+
app_kvs: list[tuple[str, str]] = []
|
|
119
|
+
for name in actx.app_names:
|
|
120
|
+
app_info = actx.apps[name]
|
|
121
|
+
port = app_info["port"]
|
|
122
|
+
is_running = app_running.get(name, False)
|
|
123
|
+
|
|
124
|
+
app_dir = actx.get_app_dir(name)
|
|
125
|
+
built = (app_dir / "dist").is_dir() or (app_dir / ".next").is_dir()
|
|
126
|
+
status_parts = []
|
|
127
|
+
if is_running:
|
|
128
|
+
status_parts.append(f"[green]:{port}[/green]")
|
|
129
|
+
if built:
|
|
130
|
+
status_parts.append("[blue]built[/blue]")
|
|
131
|
+
if not status_parts:
|
|
132
|
+
status_parts.append("[dim]idle[/dim]")
|
|
133
|
+
|
|
134
|
+
app_kvs.append((name, " ".join(status_parts)))
|
|
135
|
+
|
|
136
|
+
sections.append(("Apps", app_kvs))
|
|
137
|
+
|
|
138
|
+
root_name = data.get("project_root", "").rstrip("/").split("/")[-1]
|
|
139
|
+
out.detail(f"{root_name} Dashboard", sections, data_for_json=data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("show")
|
|
143
|
+
def show(
|
|
144
|
+
ctx: typer.Context,
|
|
145
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuously refresh.")] = False,
|
|
146
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Refresh interval in seconds.")] = 10,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Show monorepo overview dashboard."""
|
|
149
|
+
actx: AppContext = ctx.obj
|
|
150
|
+
|
|
151
|
+
if watch:
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
data = _fetch_dashboard(actx)
|
|
155
|
+
actx.output.console.clear()
|
|
156
|
+
_display_dashboard(actx, data)
|
|
157
|
+
actx.output.text(f"\n[dim]Refreshing every {interval}s. Press Ctrl+C to stop.[/dim]")
|
|
158
|
+
time.sleep(interval)
|
|
159
|
+
except KeyboardInterrupt:
|
|
160
|
+
actx.output.info("Stopped watching.")
|
|
161
|
+
else:
|
|
162
|
+
data = _fetch_dashboard(actx)
|
|
163
|
+
_display_dashboard(actx, data)
|