kctl-react 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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
+ ]