local-dev-composer 0.1.0__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 (57) hide show
  1. ldc/__init__.py +3 -0
  2. ldc/__main__.py +5 -0
  3. ldc/adapters/__init__.py +0 -0
  4. ldc/adapters/config/__init__.py +0 -0
  5. ldc/adapters/config/yaml_reader.py +172 -0
  6. ldc/adapters/git/__init__.py +0 -0
  7. ldc/adapters/git/subprocess_client.py +64 -0
  8. ldc/adapters/health/__init__.py +0 -0
  9. ldc/adapters/health/command_checker.py +41 -0
  10. ldc/adapters/health/composite_checker.py +39 -0
  11. ldc/adapters/health/http_checker.py +31 -0
  12. ldc/adapters/health/process_checker.py +28 -0
  13. ldc/adapters/health/tcp_checker.py +33 -0
  14. ldc/adapters/prerequisites/__init__.py +0 -0
  15. ldc/adapters/prerequisites/system_checker.py +256 -0
  16. ldc/adapters/process/__init__.py +0 -0
  17. ldc/adapters/process/windows_runner.py +143 -0
  18. ldc/adapters/reporting/__init__.py +0 -0
  19. ldc/adapters/reporting/rich_reporter.py +211 -0
  20. ldc/adapters/state/__init__.py +0 -0
  21. ldc/adapters/state/json_store.py +64 -0
  22. ldc/application/__init__.py +1 -0
  23. ldc/application/commands/__init__.py +0 -0
  24. ldc/application/commands/bootstrap.py +91 -0
  25. ldc/application/commands/check.py +65 -0
  26. ldc/application/commands/clone.py +77 -0
  27. ldc/application/commands/doctor.py +115 -0
  28. ldc/application/commands/down.py +61 -0
  29. ldc/application/commands/env.py +94 -0
  30. ldc/application/commands/install.py +61 -0
  31. ldc/application/commands/logs.py +74 -0
  32. ldc/application/commands/rebuild.py +86 -0
  33. ldc/application/commands/restart.py +65 -0
  34. ldc/application/commands/status.py +36 -0
  35. ldc/application/commands/up.py +229 -0
  36. ldc/application/container.py +83 -0
  37. ldc/application/env_resolver.py +61 -0
  38. ldc/application/installer_service.py +41 -0
  39. ldc/cli.py +242 -0
  40. ldc/domain/__init__.py +1 -0
  41. ldc/domain/exceptions.py +29 -0
  42. ldc/domain/graph.py +149 -0
  43. ldc/domain/models.py +172 -0
  44. ldc/ports/__init__.py +1 -0
  45. ldc/ports/config_reader.py +11 -0
  46. ldc/ports/git_client.py +22 -0
  47. ldc/ports/health_checker.py +28 -0
  48. ldc/ports/installer.py +24 -0
  49. ldc/ports/prerequisite_checker.py +23 -0
  50. ldc/ports/process_runner.py +39 -0
  51. ldc/ports/reporter.py +43 -0
  52. ldc/ports/state_store.py +20 -0
  53. local_dev_composer-0.1.0.dist-info/METADATA +88 -0
  54. local_dev_composer-0.1.0.dist-info/RECORD +57 -0
  55. local_dev_composer-0.1.0.dist-info/WHEEL +5 -0
  56. local_dev_composer-0.1.0.dist-info/entry_points.txt +2 -0
  57. local_dev_composer-0.1.0.dist-info/top_level.txt +1 -0
ldc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """local-dev-composer (ldc) — orchestrate local microservice environments."""
2
+
3
+ __version__ = "0.1.0"
ldc/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enables python -m ldc invocation."""
2
+ from ldc.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
File without changes
File without changes
@@ -0,0 +1,172 @@
1
+ """
2
+ Adapter: reads a composer.yml file and produces a WorkspaceConfig.
3
+
4
+ YAML schema is deliberately flat and human-friendly; this adapter maps it
5
+ to the rich domain model.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ import yaml
13
+
14
+ from ldc.domain.exceptions import ConfigValidationError
15
+ from ldc.domain.models import (
16
+ Group,
17
+ HealthCheckConfig,
18
+ HealthCheckType,
19
+ InstallConfig,
20
+ Prerequisites,
21
+ Runtime,
22
+ Service,
23
+ StartConfig,
24
+ WorkspaceConfig,
25
+ )
26
+ from ldc.ports.config_reader import IConfigReader
27
+
28
+
29
+ class YamlConfigReader(IConfigReader):
30
+
31
+ def read(self, path: Path) -> WorkspaceConfig:
32
+ if not path.exists():
33
+ raise ConfigValidationError(f"Config file not found: {path}")
34
+
35
+ with path.open("r", encoding="utf-8") as fh:
36
+ raw = yaml.safe_load(fh)
37
+
38
+ if not isinstance(raw, dict):
39
+ raise ConfigValidationError("composer.yml must be a YAML mapping")
40
+
41
+ workspace_raw = raw.get("workspace", {})
42
+ root = workspace_raw.get("root", "./services")
43
+ log_dir = workspace_raw.get("log_dir", "./logs")
44
+
45
+ services_raw: Dict[str, Any] = raw.get("services", {})
46
+ groups_raw: Dict[str, Any] = raw.get("groups", {})
47
+
48
+ services = {
49
+ name: self._parse_service(name, svc_raw)
50
+ for name, svc_raw in services_raw.items()
51
+ }
52
+
53
+ groups = {
54
+ name: self._parse_group(name, grp_raw)
55
+ for name, grp_raw in groups_raw.items()
56
+ }
57
+
58
+ return WorkspaceConfig(
59
+ root=root,
60
+ log_dir=log_dir,
61
+ services=services,
62
+ groups=groups,
63
+ )
64
+
65
+ # ------------------------------------------------------------------
66
+ # Private helpers
67
+ # ------------------------------------------------------------------
68
+
69
+ def _parse_service(self, name: str, raw: Dict[str, Any]) -> Service:
70
+ runtime_str = raw.get("runtime", "custom")
71
+ try:
72
+ runtime = Runtime(runtime_str.lower())
73
+ except ValueError:
74
+ raise ConfigValidationError(
75
+ f"Service '{name}': unknown runtime '{runtime_str}'. "
76
+ f"Valid values: {[r.value for r in Runtime]}"
77
+ )
78
+
79
+ return Service(
80
+ name=name,
81
+ runtime=runtime,
82
+ depends_on=raw.get("depends_on", []),
83
+ repo=raw.get("repo"),
84
+ branch=raw.get("branch", "main"),
85
+ dir=raw.get("dir"),
86
+ env=raw.get("env", {}),
87
+ env_files=self._parse_env_files(raw),
88
+ requires=self._parse_prerequisites(raw.get("requires")),
89
+ install=self._parse_install(raw.get("install")),
90
+ start=self._parse_start(raw.get("start")),
91
+ health_check=self._parse_health_check(name, raw.get("health_check")),
92
+ labels=raw.get("labels", {}),
93
+ description=raw.get("description", ""),
94
+ )
95
+
96
+ def _parse_env_files(self, raw: Dict[str, Any]) -> list:
97
+ if "env_files" in raw:
98
+ value = raw["env_files"]
99
+ return [value] if isinstance(value, str) else list(value)
100
+ if "env_file" in raw:
101
+ value = raw["env_file"]
102
+ return [value] if value else []
103
+ return []
104
+
105
+ def _parse_prerequisites(self, raw: Optional[Dict]) -> Optional[Prerequisites]:
106
+ if not raw:
107
+ return None
108
+ return Prerequisites(
109
+ java=raw.get("java"),
110
+ python=raw.get("python"),
111
+ node=raw.get("node"),
112
+ dotnet=raw.get("dotnet"),
113
+ commands=raw.get("commands", []),
114
+ env_vars=raw.get("env_vars", []),
115
+ folders=raw.get("folders", []),
116
+ files=raw.get("files", []),
117
+ ports_free=raw.get("ports_free", []),
118
+ )
119
+
120
+ def _parse_install(self, raw: Optional[Any]) -> Optional[InstallConfig]:
121
+ if not raw:
122
+ return None
123
+ if isinstance(raw, str):
124
+ return InstallConfig(command=raw)
125
+ return InstallConfig(
126
+ command=raw["command"],
127
+ working_dir=raw.get("working_dir", "."),
128
+ )
129
+
130
+ def _parse_start(self, raw: Optional[Any]) -> Optional[StartConfig]:
131
+ if not raw:
132
+ return None
133
+ if isinstance(raw, str):
134
+ return StartConfig(command=raw)
135
+ return StartConfig(
136
+ command=raw["command"],
137
+ args=raw.get("args", []),
138
+ working_dir=raw.get("working_dir", "."),
139
+ )
140
+
141
+ def _parse_health_check(
142
+ self, service_name: str, raw: Optional[Dict]
143
+ ) -> Optional[HealthCheckConfig]:
144
+ if not raw:
145
+ return None
146
+
147
+ type_str = raw.get("type", "process")
148
+ try:
149
+ hc_type = HealthCheckType(type_str.lower())
150
+ except ValueError:
151
+ raise ConfigValidationError(
152
+ f"Service '{service_name}': unknown health_check type '{type_str}'"
153
+ )
154
+
155
+ return HealthCheckConfig(
156
+ type=hc_type,
157
+ url=raw.get("url"),
158
+ host=raw.get("host", "localhost"),
159
+ port=raw.get("port"),
160
+ command=raw.get("command"),
161
+ expected_output=raw.get("expected_output"),
162
+ interval_seconds=raw.get("interval", 5),
163
+ timeout_seconds=raw.get("timeout", 60),
164
+ retries=raw.get("retries", 12),
165
+ )
166
+
167
+ def _parse_group(self, name: str, raw: Dict[str, Any]) -> Group:
168
+ return Group(
169
+ name=name,
170
+ description=raw.get("description", ""),
171
+ services=raw.get("services", []),
172
+ )
File without changes
@@ -0,0 +1,64 @@
1
+ """
2
+ Adapter: git operations via the system git binary (via subprocess).
3
+ Works on Windows with Git for Windows / GitBash.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from ldc.ports.git_client import IGitClient
11
+
12
+
13
+ class SubprocessGitClient(IGitClient):
14
+
15
+ def clone(self, repo_url: str, dest: Path, branch: str = "main") -> None:
16
+ dest.parent.mkdir(parents=True, exist_ok=True)
17
+ self._run(
18
+ ["git", "clone", "--branch", branch, "--depth", "1", repo_url, dest.name],
19
+ cwd=str(dest.parent),
20
+ )
21
+
22
+ def pull(self, repo_dir: Path, branch: str = "main") -> None:
23
+ self._run(["git", "fetch", "origin"], cwd=str(repo_dir))
24
+ self._run(
25
+ ["git", "checkout", branch], cwd=str(repo_dir)
26
+ )
27
+ self._run(
28
+ ["git", "pull", "origin", branch, "--ff-only"],
29
+ cwd=str(repo_dir),
30
+ )
31
+
32
+ def is_cloned(self, dest: Path) -> bool:
33
+ git_dir = dest / ".git"
34
+ return git_dir.exists()
35
+
36
+ def current_branch(self, repo_dir: Path) -> str:
37
+ result = self._run(
38
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
39
+ cwd=str(repo_dir),
40
+ capture=True,
41
+ )
42
+ return result.stdout.strip()
43
+
44
+ # ------------------------------------------------------------------
45
+
46
+ def _run(
47
+ self,
48
+ cmd: list,
49
+ cwd: str,
50
+ capture: bool = False,
51
+ ) -> subprocess.CompletedProcess:
52
+ result = subprocess.run(
53
+ cmd,
54
+ cwd=cwd,
55
+ capture_output=capture,
56
+ text=True,
57
+ check=False,
58
+ )
59
+ if result.returncode != 0:
60
+ stderr = result.stderr.strip() if capture else ""
61
+ raise RuntimeError(
62
+ f"git command failed (exit {result.returncode}): {' '.join(cmd)}\n{stderr}"
63
+ )
64
+ return result
File without changes
@@ -0,0 +1,41 @@
1
+ """Adapter: health check via arbitrary command exit code / output."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import time
6
+
7
+ from ldc.domain.models import HealthCheckConfig, HealthCheckType
8
+ from ldc.ports.health_checker import IHealthChecker
9
+
10
+
11
+ class CommandHealthChecker(IHealthChecker):
12
+
13
+ def supports(self, config: HealthCheckConfig) -> bool:
14
+ return config.type == HealthCheckType.COMMAND
15
+
16
+ def check(self, config: HealthCheckConfig) -> bool:
17
+ if not config.command:
18
+ return False
19
+ try:
20
+ result = subprocess.run(
21
+ config.command,
22
+ shell=True,
23
+ capture_output=True,
24
+ text=True,
25
+ timeout=10,
26
+ )
27
+ if result.returncode != 0:
28
+ return False
29
+ if config.expected_output:
30
+ return config.expected_output in (result.stdout + result.stderr)
31
+ return True
32
+ except Exception:
33
+ return False
34
+
35
+ def wait_healthy(self, config: HealthCheckConfig) -> bool:
36
+ deadline = time.monotonic() + config.timeout_seconds
37
+ while time.monotonic() < deadline:
38
+ if self.check(config):
39
+ return True
40
+ time.sleep(config.interval_seconds)
41
+ return False
@@ -0,0 +1,39 @@
1
+ """
2
+ Adapter: dispatches health checks to the appropriate concrete checker
3
+ based on HealthCheckConfig.type.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from typing import List
9
+
10
+ from ldc.domain.models import HealthCheckConfig
11
+ from ldc.ports.health_checker import IHealthChecker
12
+
13
+
14
+ class CompositeHealthChecker(IHealthChecker):
15
+ """Routes to the first registered checker that supports the config type."""
16
+
17
+ def __init__(self, checkers: List[IHealthChecker]) -> None:
18
+ self._checkers = checkers
19
+
20
+ def supports(self, config: HealthCheckConfig) -> bool:
21
+ return any(c.supports(config) for c in self._checkers)
22
+
23
+ def check(self, config: HealthCheckConfig) -> bool:
24
+ checker = self._find(config)
25
+ if checker is None:
26
+ return False
27
+ return checker.check(config)
28
+
29
+ def wait_healthy(self, config: HealthCheckConfig) -> bool:
30
+ checker = self._find(config)
31
+ if checker is None:
32
+ return False
33
+ return checker.wait_healthy(config)
34
+
35
+ def _find(self, config: HealthCheckConfig) -> "IHealthChecker | None":
36
+ for c in self._checkers:
37
+ if c.supports(config):
38
+ return c
39
+ return None
@@ -0,0 +1,31 @@
1
+ """Adapter: HTTP/HTTPS health check."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ import urllib.request
6
+ import urllib.error
7
+
8
+ from ldc.domain.models import HealthCheckConfig, HealthCheckType
9
+ from ldc.ports.health_checker import IHealthChecker
10
+
11
+
12
+ class HttpHealthChecker(IHealthChecker):
13
+
14
+ def supports(self, config: HealthCheckConfig) -> bool:
15
+ return config.type == HealthCheckType.HTTP
16
+
17
+ def check(self, config: HealthCheckConfig) -> bool:
18
+ try:
19
+ req = urllib.request.Request(config.url)
20
+ with urllib.request.urlopen(req, timeout=5) as resp:
21
+ return 200 <= resp.status < 400
22
+ except Exception:
23
+ return False
24
+
25
+ def wait_healthy(self, config: HealthCheckConfig) -> bool:
26
+ deadline = time.monotonic() + config.timeout_seconds
27
+ while time.monotonic() < deadline:
28
+ if self.check(config):
29
+ return True
30
+ time.sleep(config.interval_seconds)
31
+ return False
@@ -0,0 +1,28 @@
1
+ """Adapter: health check that simply verifies the process is alive."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ import psutil
7
+
8
+ from ldc.domain.models import HealthCheckConfig, HealthCheckType
9
+ from ldc.ports.health_checker import IHealthChecker
10
+
11
+
12
+ class ProcessHealthChecker(IHealthChecker):
13
+
14
+ def __init__(self, pid_provider) -> None:
15
+ # pid_provider: callable(service_name) -> Optional[int]
16
+ self._pid_provider = pid_provider
17
+
18
+ def supports(self, config: HealthCheckConfig) -> bool:
19
+ return config.type == HealthCheckType.PROCESS
20
+
21
+ def check(self, config: HealthCheckConfig) -> bool:
22
+ # For PROCESS type we just need any live process — the caller supplies PID
23
+ # via the provider; config carries no PID directly.
24
+ # This checker is used as a fallback by the orchestrator passing PID explicitly.
25
+ return True # orchestrator checks is_alive() directly for PROCESS type
26
+
27
+ def wait_healthy(self, config: HealthCheckConfig) -> bool:
28
+ return True
@@ -0,0 +1,33 @@
1
+ """Adapter: TCP port health check."""
2
+ from __future__ import annotations
3
+
4
+ import socket
5
+ import time
6
+
7
+ from ldc.domain.models import HealthCheckConfig, HealthCheckType
8
+ from ldc.ports.health_checker import IHealthChecker
9
+
10
+
11
+ class TcpHealthChecker(IHealthChecker):
12
+
13
+ def supports(self, config: HealthCheckConfig) -> bool:
14
+ return config.type == HealthCheckType.TCP
15
+
16
+ def check(self, config: HealthCheckConfig) -> bool:
17
+ host = config.host or "localhost"
18
+ port = config.port
19
+ if not port:
20
+ return False
21
+ try:
22
+ with socket.create_connection((host, port), timeout=3):
23
+ return True
24
+ except (OSError, socket.timeout):
25
+ return False
26
+
27
+ def wait_healthy(self, config: HealthCheckConfig) -> bool:
28
+ deadline = time.monotonic() + config.timeout_seconds
29
+ while time.monotonic() < deadline:
30
+ if self.check(config):
31
+ return True
32
+ time.sleep(config.interval_seconds)
33
+ return False
File without changes
@@ -0,0 +1,256 @@
1
+ """
2
+ Adapter: checks host prerequisites (runtimes, commands, env vars, folders, files, ports).
3
+
4
+ Every check produces a CheckResult with a human-readable fix_hint so the
5
+ user always knows exactly what to do when something is missing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import socket
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import List, Optional, Tuple
16
+
17
+ from ldc.domain.models import CheckResult, Prerequisites, PrerequisiteReport
18
+ from ldc.ports.prerequisite_checker import IPrerequisiteChecker
19
+
20
+
21
+ class SystemPrerequisiteChecker(IPrerequisiteChecker):
22
+
23
+ def check(self, service_name: str, prereqs: Prerequisites) -> PrerequisiteReport:
24
+ results: List[CheckResult] = []
25
+
26
+ if prereqs.java:
27
+ results.append(self._check_runtime("java", prereqs.java))
28
+ if prereqs.python:
29
+ results.append(self._check_runtime("python", prereqs.python))
30
+ if prereqs.node:
31
+ results.append(self._check_runtime("node", prereqs.node))
32
+ if prereqs.dotnet:
33
+ results.append(self._check_runtime("dotnet", prereqs.dotnet))
34
+
35
+ for cmd in prereqs.commands:
36
+ results.append(self._check_command(cmd))
37
+
38
+ for var in prereqs.env_vars:
39
+ results.append(self._check_env_var(var))
40
+
41
+ for folder in prereqs.folders:
42
+ results.append(self._check_folder(folder))
43
+
44
+ for filepath in prereqs.files:
45
+ results.append(self._check_file(filepath))
46
+
47
+ for port in prereqs.ports_free:
48
+ results.append(self._check_port_free(port))
49
+
50
+ return PrerequisiteReport(service_name=service_name, checks=results)
51
+
52
+ def auto_fix(self, service_name: str, prereqs: Prerequisites) -> PrerequisiteReport:
53
+ """Only auto-fixable action: create missing folders."""
54
+ for folder in prereqs.folders:
55
+ try:
56
+ Path(folder).mkdir(parents=True, exist_ok=True)
57
+ except Exception:
58
+ pass
59
+ return self.check(service_name, prereqs)
60
+
61
+ # ------------------------------------------------------------------
62
+ # Individual checks
63
+ # ------------------------------------------------------------------
64
+
65
+ def _check_runtime(self, runtime: str, constraint: str) -> CheckResult:
66
+ binary_map = {
67
+ "java": ("java", "-version"),
68
+ "python": ("python", "--version"),
69
+ "node": ("node", "--version"),
70
+ "dotnet": ("dotnet", "--version"),
71
+ }
72
+ binary, flag = binary_map[runtime]
73
+ installed_ver = self._get_version(binary, flag)
74
+
75
+ if installed_ver is None:
76
+ return CheckResult(
77
+ name=f"{runtime} runtime",
78
+ passed=False,
79
+ message=f"'{binary}' not found on PATH",
80
+ fix_hint=self._runtime_install_hint(runtime),
81
+ )
82
+
83
+ satisfied, reason = self._version_satisfies(installed_ver, constraint)
84
+ return CheckResult(
85
+ name=f"{runtime} runtime",
86
+ passed=satisfied,
87
+ message=f"{runtime} {installed_ver} — required: {constraint}",
88
+ fix_hint=None if satisfied else self._runtime_install_hint(runtime),
89
+ )
90
+
91
+ def _check_command(self, cmd: str) -> CheckResult:
92
+ found = shutil.which(cmd) is not None
93
+ return CheckResult(
94
+ name=f"command '{cmd}'",
95
+ passed=found,
96
+ message=f"'{cmd}' {'found on PATH' if found else 'NOT found on PATH'}",
97
+ fix_hint=(
98
+ None
99
+ if found
100
+ else f"Install '{cmd}' and ensure it is on your PATH. "
101
+ f"On Windows you can check: where {cmd}"
102
+ ),
103
+ )
104
+
105
+ def _check_env_var(self, var: str) -> CheckResult:
106
+ value = os.environ.get(var)
107
+ present = value is not None and value.strip() != ""
108
+ return CheckResult(
109
+ name=f"env var '{var}'",
110
+ passed=present,
111
+ message=f"${var} {'is set' if present else 'is NOT set'}",
112
+ fix_hint=(
113
+ None
114
+ if present
115
+ else f"Set the environment variable: setx {var} \"<value>\" "
116
+ f"(system-wide) or add it to your .env file"
117
+ ),
118
+ )
119
+
120
+ def _check_folder(self, folder: str) -> CheckResult:
121
+ path = Path(folder)
122
+ exists = path.exists() and path.is_dir()
123
+ return CheckResult(
124
+ name=f"folder '{folder}'",
125
+ passed=exists,
126
+ message=f"'{folder}' {'exists' if exists else 'does NOT exist'}",
127
+ fix_hint=(
128
+ None
129
+ if exists
130
+ else f"Create the folder: mkdir -p \"{folder}\" "
131
+ f"(or run: ldc check --fix to auto-create)"
132
+ ),
133
+ )
134
+
135
+ def _check_file(self, filepath: str) -> CheckResult:
136
+ path = Path(filepath)
137
+ exists = path.exists() and path.is_file()
138
+ return CheckResult(
139
+ name=f"file '{filepath}'",
140
+ passed=exists,
141
+ message=f"'{filepath}' {'exists' if exists else 'does NOT exist'}",
142
+ fix_hint=(
143
+ None
144
+ if exists
145
+ else f"Create or copy the required file to: {filepath}"
146
+ ),
147
+ )
148
+
149
+ def _check_port_free(self, port: int) -> CheckResult:
150
+ in_use = self._is_port_in_use(port)
151
+ return CheckResult(
152
+ name=f"port {port} free",
153
+ passed=not in_use,
154
+ message=f"Port {port} is {'in use' if in_use else 'free'}",
155
+ fix_hint=(
156
+ None
157
+ if not in_use
158
+ else f"Port {port} is occupied. Find the process: "
159
+ f"netstat -ano | findstr :{port} "
160
+ f"then terminate it or change the service port."
161
+ ),
162
+ )
163
+
164
+ # ------------------------------------------------------------------
165
+ # Version utilities
166
+ # ------------------------------------------------------------------
167
+
168
+ def _get_version(self, binary: str, flag: str) -> Optional[str]:
169
+ try:
170
+ result = subprocess.run(
171
+ [binary, flag],
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=10,
175
+ )
176
+ # java -version prints to stderr; others to stdout
177
+ output = (result.stdout + result.stderr).strip()
178
+ return self._extract_version(output)
179
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
180
+ return None
181
+
182
+ _VERSION_RE = re.compile(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?")
183
+
184
+ def _extract_version(self, text: str) -> Optional[str]:
185
+ m = self._VERSION_RE.search(text)
186
+ if not m:
187
+ return None
188
+ parts = [m.group(i) or "0" for i in (1, 2, 3)]
189
+ return ".".join(parts)
190
+
191
+ def _version_satisfies(
192
+ self, installed: str, constraint: str
193
+ ) -> Tuple[bool, str]:
194
+ """Evaluate simple constraints like '>=17', '>11', '==3.9'."""
195
+ m = re.match(r"^(>=|>|<=|<|==)(\d+(?:\.\d+)?(?:\.\d+)?)$", constraint.strip())
196
+ if not m:
197
+ return True, "unparseable constraint — skipped"
198
+
199
+ op, required_str = m.group(1), m.group(2)
200
+ inst_tuple = self._ver_tuple(installed)
201
+ req_tuple = self._ver_tuple(required_str)
202
+
203
+ ops = {
204
+ ">=": inst_tuple >= req_tuple,
205
+ ">": inst_tuple > req_tuple,
206
+ "<=": inst_tuple <= req_tuple,
207
+ "<": inst_tuple < req_tuple,
208
+ "==": inst_tuple == req_tuple,
209
+ }
210
+ satisfied = ops[op]
211
+ return satisfied, f"{installed} {op} {required_str}"
212
+
213
+ @staticmethod
214
+ def _ver_tuple(ver: str) -> tuple:
215
+ parts = ver.split(".")
216
+ result = []
217
+ for p in parts[:3]:
218
+ try:
219
+ result.append(int(p))
220
+ except ValueError:
221
+ result.append(0)
222
+ while len(result) < 3:
223
+ result.append(0)
224
+ return tuple(result)
225
+
226
+ @staticmethod
227
+ def _is_port_in_use(port: int) -> bool:
228
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
229
+ try:
230
+ s.bind(("127.0.0.1", port))
231
+ return False
232
+ except OSError:
233
+ return True
234
+
235
+ @staticmethod
236
+ def _runtime_install_hint(runtime: str) -> str:
237
+ hints = {
238
+ "java": (
239
+ "Install JDK and add JAVA_HOME to system env vars. "
240
+ "Download from: https://adoptium.net "
241
+ "Then: setx JAVA_HOME \"C:\\Program Files\\Eclipse Adoptium\\jdk-XX\""
242
+ ),
243
+ "python": (
244
+ "Install Python from https://python.org/downloads "
245
+ "and ensure 'Add Python to PATH' is checked during setup."
246
+ ),
247
+ "node": (
248
+ "Install Node.js from https://nodejs.org "
249
+ "and ensure the installer adds it to PATH."
250
+ ),
251
+ "dotnet": (
252
+ "Install .NET SDK from https://dotnet.microsoft.com/download "
253
+ "and restart your terminal."
254
+ ),
255
+ }
256
+ return hints.get(runtime, f"Install '{runtime}' and add it to PATH.")