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,61 @@
|
|
|
1
|
+
"""Fixer: sync missing i18n keys between en.json and id.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _flatten_keys(d: dict[str, Any], prefix: str = "") -> set[str]:
|
|
11
|
+
"""Flatten nested dict keys to dot notation."""
|
|
12
|
+
keys: set[str] = set()
|
|
13
|
+
for k, v in d.items():
|
|
14
|
+
full = f"{prefix}{k}" if not prefix else f"{prefix}.{k}"
|
|
15
|
+
if isinstance(v, dict):
|
|
16
|
+
keys.update(_flatten_keys(v, full))
|
|
17
|
+
else:
|
|
18
|
+
keys.add(full)
|
|
19
|
+
return keys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _set_nested(d: dict[str, Any], dotted_key: str, value: Any) -> None:
|
|
23
|
+
"""Set a nested key using dot notation, creating intermediate dicts."""
|
|
24
|
+
parts = dotted_key.split(".")
|
|
25
|
+
for part in parts[:-1]:
|
|
26
|
+
d = d.setdefault(part, {})
|
|
27
|
+
d[parts[-1]] = value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fix_i18n(app_path: Path, app_name: str, dry_run: bool = False) -> int:
|
|
31
|
+
"""Add missing keys (as empty strings) to en.json / id.json. Return fix count."""
|
|
32
|
+
i18n_dir = app_path / "src" / "i18n"
|
|
33
|
+
en_path = i18n_dir / "en.json"
|
|
34
|
+
id_path = i18n_dir / "id.json"
|
|
35
|
+
|
|
36
|
+
if not en_path.is_file() or not id_path.is_file():
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
en_data: dict[str, Any] = json.loads(en_path.read_text(encoding="utf-8"))
|
|
40
|
+
id_data: dict[str, Any] = json.loads(id_path.read_text(encoding="utf-8"))
|
|
41
|
+
|
|
42
|
+
en_keys = _flatten_keys(en_data)
|
|
43
|
+
id_keys = _flatten_keys(id_data)
|
|
44
|
+
|
|
45
|
+
count = 0
|
|
46
|
+
|
|
47
|
+
# Keys in en but missing in id
|
|
48
|
+
for key in sorted(en_keys - id_keys):
|
|
49
|
+
_set_nested(id_data, key, "")
|
|
50
|
+
count += 1
|
|
51
|
+
|
|
52
|
+
# Keys in id but missing in en
|
|
53
|
+
for key in sorted(id_keys - en_keys):
|
|
54
|
+
_set_nested(en_data, key, "")
|
|
55
|
+
count += 1
|
|
56
|
+
|
|
57
|
+
if count > 0 and not dry_run:
|
|
58
|
+
en_path.write_text(json.dumps(en_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
59
|
+
id_path.write_text(json.dumps(id_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
return count
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Fixer: remove 'use client' directives from .ts/.tsx files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_USE_CLIENT_RE = re.compile(r'^["\']use client["\']\s*;?\s*\n', re.MULTILINE)
|
|
9
|
+
|
|
10
|
+
_SKIP_DIRS = {"node_modules", "generated", ".git", "dist", "__pycache__"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fix_imports(app_path: Path, app_name: str, dry_run: bool = False) -> int:
|
|
14
|
+
"""Remove 'use client' directives. Return count of fixes applied."""
|
|
15
|
+
src = app_path / "src"
|
|
16
|
+
if not src.is_dir():
|
|
17
|
+
return 0
|
|
18
|
+
|
|
19
|
+
count = 0
|
|
20
|
+
for f in src.rglob("*"):
|
|
21
|
+
if not f.is_file():
|
|
22
|
+
continue
|
|
23
|
+
if f.suffix not in (".ts", ".tsx"):
|
|
24
|
+
continue
|
|
25
|
+
# Skip excluded directories
|
|
26
|
+
if any(part in _SKIP_DIRS for part in f.relative_to(src).parts):
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
text = f.read_text(encoding="utf-8")
|
|
30
|
+
new_text = _USE_CLIENT_RE.sub("", text)
|
|
31
|
+
if new_text != text:
|
|
32
|
+
if not dry_run:
|
|
33
|
+
f.write_text(new_text, encoding="utf-8")
|
|
34
|
+
count += 1
|
|
35
|
+
|
|
36
|
+
return count
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Fixer: create missing required directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from kctl_react.core.compliance.checks.structure import REQUIRED_DIRS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fix_structure(app_path: Path, app_name: str, dry_run: bool = False) -> int:
|
|
11
|
+
"""Create missing required directories. Return count of fixes applied."""
|
|
12
|
+
src = app_path / "src"
|
|
13
|
+
count = 0
|
|
14
|
+
for d in REQUIRED_DIRS:
|
|
15
|
+
target = src / d
|
|
16
|
+
if not target.is_dir():
|
|
17
|
+
if not dry_run:
|
|
18
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
count += 1
|
|
20
|
+
return count
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Fixer: correct theme import path in index.css."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_THEME_IMPORT_RE = re.compile(r'@import\s+["\']@kodemeio/tailwind-config/themes/\w+\.css["\']')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fix_theme(app_path: Path, app_name: str, dry_run: bool = False) -> int:
|
|
12
|
+
"""Fix theme import path in index.css. Return count of fixes applied."""
|
|
13
|
+
index_css = app_path / "src" / "index.css"
|
|
14
|
+
if not index_css.is_file():
|
|
15
|
+
return 0
|
|
16
|
+
|
|
17
|
+
text = index_css.read_text(encoding="utf-8")
|
|
18
|
+
correct_import = f'@import "@kodemeio/tailwind-config/themes/{app_name}.css"'
|
|
19
|
+
|
|
20
|
+
if correct_import in text:
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
new_text = _THEME_IMPORT_RE.sub(correct_import, text)
|
|
24
|
+
if new_text != text:
|
|
25
|
+
if not dry_run:
|
|
26
|
+
index_css.write_text(new_text, encoding="utf-8")
|
|
27
|
+
return 1
|
|
28
|
+
|
|
29
|
+
return 0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Data models for compliance audit results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_grade(score: int) -> str:
|
|
9
|
+
"""Convert 0-100 percentage to letter grade."""
|
|
10
|
+
if score >= 95:
|
|
11
|
+
return "A+"
|
|
12
|
+
if score >= 90:
|
|
13
|
+
return "A"
|
|
14
|
+
if score >= 80:
|
|
15
|
+
return "B"
|
|
16
|
+
if score >= 70:
|
|
17
|
+
return "C"
|
|
18
|
+
if score >= 60:
|
|
19
|
+
return "D"
|
|
20
|
+
return "F"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Violation:
|
|
25
|
+
file: str
|
|
26
|
+
line: int | None = None
|
|
27
|
+
message: str = ""
|
|
28
|
+
fix_hint: str | None = None
|
|
29
|
+
auto_fixable: bool = False
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"file": self.file,
|
|
34
|
+
"line": self.line,
|
|
35
|
+
"message": self.message,
|
|
36
|
+
"fix_hint": self.fix_hint,
|
|
37
|
+
"auto_fixable": self.auto_fixable,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CategoryResult:
|
|
43
|
+
name: str
|
|
44
|
+
label: str
|
|
45
|
+
max_points: int
|
|
46
|
+
violations: list[Violation] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def score(self) -> int:
|
|
50
|
+
"""max_points minus len(violations), capped at 0."""
|
|
51
|
+
return max(0, self.max_points - len(self.violations))
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"name": self.name,
|
|
56
|
+
"label": self.label,
|
|
57
|
+
"max_points": self.max_points,
|
|
58
|
+
"score": self.score,
|
|
59
|
+
"violations": [v.to_dict() for v in self.violations],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AppReport:
|
|
65
|
+
app: str
|
|
66
|
+
categories: list[CategoryResult] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def total_score(self) -> int:
|
|
70
|
+
return sum(c.score for c in self.categories)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def max_score(self) -> int:
|
|
74
|
+
return sum(c.max_points for c in self.categories)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def grade(self) -> str:
|
|
78
|
+
"""Percentage-based grade: total_score / max_score * 100."""
|
|
79
|
+
if self.max_score == 0:
|
|
80
|
+
return "A+"
|
|
81
|
+
pct = int(self.total_score / self.max_score * 100)
|
|
82
|
+
return compute_grade(pct)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def total_violations(self) -> int:
|
|
86
|
+
return sum(len(c.violations) for c in self.categories)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def auto_fixable_count(self) -> int:
|
|
90
|
+
return sum(1 for c in self.categories for v in c.violations if v.auto_fixable)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def needs_review_count(self) -> int:
|
|
94
|
+
return self.total_violations - self.auto_fixable_count
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"app": self.app,
|
|
99
|
+
"total_score": self.total_score,
|
|
100
|
+
"max_score": self.max_score,
|
|
101
|
+
"grade": self.grade,
|
|
102
|
+
"total_violations": self.total_violations,
|
|
103
|
+
"auto_fixable_count": self.auto_fixable_count,
|
|
104
|
+
"needs_review_count": self.needs_review_count,
|
|
105
|
+
"categories": [c.to_dict() for c in self.categories],
|
|
106
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Config management — delegates to kctl-lib with react-specific ServiceConfig.
|
|
2
|
+
|
|
3
|
+
Core I/O (load_raw_config / save_raw_config) uses module-level CONFIG_DIR /
|
|
4
|
+
CONFIG_FILE so that tests can monkeypatch those names. is_service_scoped and
|
|
5
|
+
ConfigFile are re-used from kctl_lib; all file-touching functions are local.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from kctl_lib.config import ConfigFile, is_service_scoped
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
SERVICE_KEY = "react"
|
|
20
|
+
ENV_PREFIX = "KCTL_REACT"
|
|
21
|
+
|
|
22
|
+
CONFIG_DIR = Path.home() / ".config" / "kodemeio"
|
|
23
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CONFIG_DIR",
|
|
27
|
+
"CONFIG_FILE",
|
|
28
|
+
"ServiceConfig",
|
|
29
|
+
"get_all_services_in_profile",
|
|
30
|
+
"get_default_profile",
|
|
31
|
+
"get_profile_names",
|
|
32
|
+
"get_service_config",
|
|
33
|
+
"load_config",
|
|
34
|
+
"load_raw_config",
|
|
35
|
+
"remove_profile",
|
|
36
|
+
"resolve_active_profile_name",
|
|
37
|
+
"resolve_project_root",
|
|
38
|
+
"save_raw_config",
|
|
39
|
+
"set_default_profile",
|
|
40
|
+
"set_service_config",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServiceConfig(BaseModel):
|
|
45
|
+
"""Service-specific config within a profile."""
|
|
46
|
+
|
|
47
|
+
project_root: str = ""
|
|
48
|
+
api_url: str = ""
|
|
49
|
+
odoo_url: str = ""
|
|
50
|
+
odoo_db: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_raw_config() -> dict[str, Any]:
|
|
54
|
+
"""Load raw YAML config from disk."""
|
|
55
|
+
import kctl_react.core.config as _self
|
|
56
|
+
|
|
57
|
+
cf = _self.CONFIG_FILE
|
|
58
|
+
if not cf.exists():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
with open(cf) as f:
|
|
62
|
+
return yaml.safe_load(f) or {}
|
|
63
|
+
except (yaml.YAMLError, OSError) as e:
|
|
64
|
+
print(f"WARN: Cannot read {cf}: {e}", file=sys.stderr)
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_raw_config(data: dict[str, Any]) -> None:
|
|
69
|
+
"""Write raw config dict to YAML file."""
|
|
70
|
+
import kctl_react.core.config as _self
|
|
71
|
+
|
|
72
|
+
cd = _self.CONFIG_DIR
|
|
73
|
+
cf = _self.CONFIG_FILE
|
|
74
|
+
cd.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
with open(cf, "w") as f:
|
|
76
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config() -> ConfigFile:
|
|
80
|
+
"""Load and validate the config file."""
|
|
81
|
+
data = load_raw_config()
|
|
82
|
+
return ConfigFile(
|
|
83
|
+
default_profile=data.get("default_profile", "default"),
|
|
84
|
+
profiles=data.get("profiles", {}),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_service_config(profile_name: str) -> ServiceConfig:
|
|
89
|
+
"""Get the 'react' service config from a profile."""
|
|
90
|
+
cfg = load_config()
|
|
91
|
+
profile_data = cfg.profiles.get(profile_name, {})
|
|
92
|
+
if not profile_data:
|
|
93
|
+
return ServiceConfig()
|
|
94
|
+
if is_service_scoped(profile_data):
|
|
95
|
+
svc_data = profile_data.get(SERVICE_KEY, {})
|
|
96
|
+
if isinstance(svc_data, dict):
|
|
97
|
+
return ServiceConfig(**{k: v for k, v in svc_data.items() if k in ServiceConfig.model_fields})
|
|
98
|
+
return ServiceConfig()
|
|
99
|
+
else:
|
|
100
|
+
return ServiceConfig(**{k: v for k, v in profile_data.items() if k in ServiceConfig.model_fields})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_service_config(profile_name: str, svc_config: ServiceConfig) -> None:
|
|
104
|
+
"""Set the 'react' service config within a profile."""
|
|
105
|
+
data = load_raw_config()
|
|
106
|
+
if "profiles" not in data:
|
|
107
|
+
data["profiles"] = {}
|
|
108
|
+
if profile_name not in data["profiles"]:
|
|
109
|
+
data["profiles"][profile_name] = {}
|
|
110
|
+
profile = data["profiles"][profile_name]
|
|
111
|
+
if not is_service_scoped(profile):
|
|
112
|
+
old_data = dict(profile)
|
|
113
|
+
profile.clear()
|
|
114
|
+
profile[SERVICE_KEY] = old_data
|
|
115
|
+
svc_data = svc_config.model_dump(exclude_defaults=False)
|
|
116
|
+
for key in list(svc_data.keys()):
|
|
117
|
+
if not svc_data.get(key):
|
|
118
|
+
svc_data.pop(key, None)
|
|
119
|
+
profile[SERVICE_KEY] = svc_data
|
|
120
|
+
save_raw_config(data)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_profile_names() -> list[str]:
|
|
124
|
+
"""Return all profile names."""
|
|
125
|
+
return list(load_config().profiles.keys())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_all_services_in_profile(profile_name: str) -> dict[str, dict[str, Any]]:
|
|
129
|
+
"""Return all service configs within a profile."""
|
|
130
|
+
cfg = load_config()
|
|
131
|
+
profile_data = cfg.profiles.get(profile_name, {})
|
|
132
|
+
if is_service_scoped(profile_data):
|
|
133
|
+
return {k: v for k, v in profile_data.items() if isinstance(v, dict)}
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_default_profile() -> str:
|
|
138
|
+
"""Return the default profile name."""
|
|
139
|
+
return load_config().default_profile
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def set_default_profile(name: str) -> None:
|
|
143
|
+
"""Set the default profile name."""
|
|
144
|
+
data = load_raw_config()
|
|
145
|
+
data["default_profile"] = name
|
|
146
|
+
save_raw_config(data)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def remove_profile(name: str) -> None:
|
|
150
|
+
"""Remove a profile by name."""
|
|
151
|
+
data = load_raw_config()
|
|
152
|
+
profiles = data.get("profiles", {})
|
|
153
|
+
profiles.pop(name, None)
|
|
154
|
+
if data.get("default_profile") == name:
|
|
155
|
+
data["default_profile"] = next(iter(profiles), "default")
|
|
156
|
+
save_raw_config(data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_active_profile_name(profile_name: str | None = None) -> str:
|
|
160
|
+
"""Resolve active profile: explicit > env > default."""
|
|
161
|
+
if profile_name:
|
|
162
|
+
return profile_name
|
|
163
|
+
if env := os.environ.get(f"{ENV_PREFIX}_PROFILE"):
|
|
164
|
+
return env
|
|
165
|
+
return get_default_profile()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def resolve_project_root(
|
|
169
|
+
profile_name: str | None = None,
|
|
170
|
+
root_override: str | None = None,
|
|
171
|
+
) -> Path:
|
|
172
|
+
"""Resolve the monorepo project root.
|
|
173
|
+
|
|
174
|
+
Priority:
|
|
175
|
+
1. CLI --root flag
|
|
176
|
+
2. KCTL_REACT_ROOT env var
|
|
177
|
+
3. Profile config project_root
|
|
178
|
+
4. Auto-detect from CWD (walk up to find turbo.json)
|
|
179
|
+
"""
|
|
180
|
+
# 1. CLI override
|
|
181
|
+
if root_override:
|
|
182
|
+
return Path(root_override)
|
|
183
|
+
|
|
184
|
+
# 2. Env var
|
|
185
|
+
if env_root := os.environ.get("KCTL_REACT_ROOT"):
|
|
186
|
+
return Path(env_root)
|
|
187
|
+
|
|
188
|
+
# 3. Profile config
|
|
189
|
+
pname = resolve_active_profile_name(profile_name)
|
|
190
|
+
svc = get_service_config(pname)
|
|
191
|
+
if svc.project_root:
|
|
192
|
+
return Path(svc.project_root)
|
|
193
|
+
|
|
194
|
+
# 4. Auto-detect from CWD
|
|
195
|
+
cwd = Path.cwd()
|
|
196
|
+
for parent in [cwd, *cwd.parents]:
|
|
197
|
+
if (parent / "turbo.json").exists() and (parent / "apps").is_dir():
|
|
198
|
+
return parent
|
|
199
|
+
|
|
200
|
+
# Fallback to CWD
|
|
201
|
+
return cwd
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Auto-discover apps and packages from the monorepo filesystem.
|
|
2
|
+
|
|
3
|
+
Scans apps/ and packages/ directories, reads package.json files,
|
|
4
|
+
and extracts metadata (name, version, port, description).
|
|
5
|
+
Works with any Turbo + pnpm monorepo, not just Kodemeio.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _extract_port(dev_script: str) -> int:
|
|
17
|
+
"""Extract port number from a vite dev script like 'vite --port 4004'."""
|
|
18
|
+
match = re.search(r"--port\s+(\d+)", dev_script)
|
|
19
|
+
if match:
|
|
20
|
+
return int(match.group(1))
|
|
21
|
+
# Try PORT= env var pattern
|
|
22
|
+
match = re.search(r"PORT=(\d+)", dev_script)
|
|
23
|
+
if match:
|
|
24
|
+
return int(match.group(1))
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_framework(app_dir: Path) -> str:
|
|
29
|
+
"""Detect whether an app uses Vite or Next.js.
|
|
30
|
+
|
|
31
|
+
Returns 'nextjs' if next.config.ts/js/mjs exists, otherwise 'vite'.
|
|
32
|
+
"""
|
|
33
|
+
for name in ("next.config.ts", "next.config.js", "next.config.mjs"):
|
|
34
|
+
if (app_dir / name).exists():
|
|
35
|
+
return "nextjs"
|
|
36
|
+
return "vite"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_pkg_json(path: Path) -> dict:
|
|
40
|
+
"""Safely read and parse a package.json file."""
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(path.read_text())
|
|
43
|
+
except Exception:
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
APP_TYPE_DIRS = ("spa", "web", "api")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def discover_apps(root: Path) -> dict[str, dict[str, Any]]:
|
|
51
|
+
"""Discover apps from the apps/ directory.
|
|
52
|
+
|
|
53
|
+
Scans apps/{spa,web,api}/ subdirectories for nested app structure.
|
|
54
|
+
Falls back to flat apps/ scanning for legacy repos.
|
|
55
|
+
|
|
56
|
+
Returns a dict like:
|
|
57
|
+
{"sfa": {"port": 4004, "name": "Sales Force Automation", "package": "@kodemeio/sfa", "type": "spa", "path": Path(...)}}
|
|
58
|
+
|
|
59
|
+
Works with any Turbo monorepo — reads package.json for metadata.
|
|
60
|
+
"""
|
|
61
|
+
apps_dir = root / "apps"
|
|
62
|
+
if not apps_dir.is_dir():
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
registry: dict[str, dict[str, Any]] = {}
|
|
66
|
+
|
|
67
|
+
# Try nested structure first: apps/{spa,web,api}/{app}
|
|
68
|
+
found_nested = False
|
|
69
|
+
for type_name in APP_TYPE_DIRS:
|
|
70
|
+
type_dir = apps_dir / type_name
|
|
71
|
+
if not type_dir.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
found_nested = True
|
|
75
|
+
for app_dir in sorted(type_dir.iterdir()):
|
|
76
|
+
if not app_dir.is_dir():
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
pkg_file = app_dir / "package.json"
|
|
80
|
+
if not pkg_file.exists():
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
pkg = _read_pkg_json(pkg_file)
|
|
84
|
+
if not pkg:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
dev_script = pkg.get("scripts", {}).get("dev", "")
|
|
88
|
+
port = _extract_port(dev_script)
|
|
89
|
+
pkg_name = pkg.get("name", app_dir.name)
|
|
90
|
+
description = pkg.get("description", "")
|
|
91
|
+
|
|
92
|
+
registry[app_dir.name] = {
|
|
93
|
+
"port": port,
|
|
94
|
+
"name": description or app_dir.name,
|
|
95
|
+
"package": pkg_name,
|
|
96
|
+
"framework": detect_framework(app_dir),
|
|
97
|
+
"type": type_name,
|
|
98
|
+
"path": app_dir,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if found_nested:
|
|
102
|
+
return registry
|
|
103
|
+
|
|
104
|
+
# Legacy fallback: flat apps/{app} structure
|
|
105
|
+
for app_dir in sorted(apps_dir.iterdir()):
|
|
106
|
+
if not app_dir.is_dir():
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
pkg_file = app_dir / "package.json"
|
|
110
|
+
if not pkg_file.exists():
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
pkg = _read_pkg_json(pkg_file)
|
|
114
|
+
if not pkg:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
dev_script = pkg.get("scripts", {}).get("dev", "")
|
|
118
|
+
port = _extract_port(dev_script)
|
|
119
|
+
pkg_name = pkg.get("name", app_dir.name)
|
|
120
|
+
description = pkg.get("description", "")
|
|
121
|
+
|
|
122
|
+
registry[app_dir.name] = {
|
|
123
|
+
"port": port,
|
|
124
|
+
"name": description or app_dir.name,
|
|
125
|
+
"package": pkg_name,
|
|
126
|
+
"framework": detect_framework(app_dir),
|
|
127
|
+
"type": "spa",
|
|
128
|
+
"path": app_dir,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return registry
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_app_dir(root: Path, app_name: str, registry: dict[str, dict[str, Any]] | None = None) -> Path:
|
|
135
|
+
"""Get the filesystem path for an app, using registry or fallback scanning."""
|
|
136
|
+
if registry and app_name in registry:
|
|
137
|
+
return registry[app_name]["path"]
|
|
138
|
+
# Fallback: scan for it
|
|
139
|
+
for type_name in APP_TYPE_DIRS:
|
|
140
|
+
candidate = root / "apps" / type_name / app_name
|
|
141
|
+
if candidate.is_dir():
|
|
142
|
+
return candidate
|
|
143
|
+
# Legacy fallback for backwards compat
|
|
144
|
+
return root / "apps" / app_name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def discover_packages(root: Path) -> list[str]:
|
|
148
|
+
"""Discover shared packages from the packages/ directory.
|
|
149
|
+
|
|
150
|
+
Returns a list of package directory names: ["core", "ui", "tailwind-config", ...]
|
|
151
|
+
"""
|
|
152
|
+
packages_dir = root / "packages"
|
|
153
|
+
if not packages_dir.is_dir():
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
result: list[str] = []
|
|
157
|
+
for pkg_dir in sorted(packages_dir.iterdir()):
|
|
158
|
+
if not pkg_dir.is_dir():
|
|
159
|
+
continue
|
|
160
|
+
if (pkg_dir / "package.json").exists():
|
|
161
|
+
result.append(pkg_dir.name)
|
|
162
|
+
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_monorepo_scope(root: Path) -> str:
|
|
167
|
+
"""Detect the npm scope (e.g. '@kodemeio' or '@kontenos') from root package.json.
|
|
168
|
+
|
|
169
|
+
Falls back to reading the first app's package name.
|
|
170
|
+
"""
|
|
171
|
+
# Try root package.json
|
|
172
|
+
root_pkg = _read_pkg_json(root / "package.json")
|
|
173
|
+
root_name = root_pkg.get("name", "")
|
|
174
|
+
if root_name.startswith("@"):
|
|
175
|
+
return root_name.split("/")[0]
|
|
176
|
+
|
|
177
|
+
# Try first app
|
|
178
|
+
apps = discover_apps(root)
|
|
179
|
+
for info in apps.values():
|
|
180
|
+
pkg_name = info.get("package", "")
|
|
181
|
+
if pkg_name.startswith("@"):
|
|
182
|
+
return pkg_name.split("/")[0]
|
|
183
|
+
|
|
184
|
+
# Try pnpm-workspace.yaml for name hints
|
|
185
|
+
return ""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exception hierarchy — re-exported from kctl-lib."""
|
|
2
|
+
|
|
3
|
+
from kctl_lib.exceptions import (
|
|
4
|
+
AppNotFoundError,
|
|
5
|
+
CommandError,
|
|
6
|
+
ConfigError,
|
|
7
|
+
KctlError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AppNotFoundError",
|
|
13
|
+
"CommandError",
|
|
14
|
+
"ConfigError",
|
|
15
|
+
"KctlError",
|
|
16
|
+
"NotFoundError",
|
|
17
|
+
]
|