ndev-stack 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 (79) hide show
  1. ndev/__init__.py +8 -0
  2. ndev/__main__.py +4 -0
  3. ndev/cli.py +24 -0
  4. ndev/common/__init__.py +3 -0
  5. ndev/common/config.py +114 -0
  6. ndev/common/constants.py +51 -0
  7. ndev/common/github.py +13 -0
  8. ndev/common/logger.py +11 -0
  9. ndev/common/manifest.py +41 -0
  10. ndev/common/utils.py +96 -0
  11. ndev/linux/__init__.py +1 -0
  12. ndev/linux/chroot/manager.py +63 -0
  13. ndev/linux/chroot/packages.py +91 -0
  14. ndev/linux/chroot/shell.py +9 -0
  15. ndev/linux/cli.py +235 -0
  16. ndev/linux/commands/available.py +38 -0
  17. ndev/linux/commands/clean.py +25 -0
  18. ndev/linux/commands/ctl.py +192 -0
  19. ndev/linux/commands/current.py +11 -0
  20. ndev/linux/commands/db.py +319 -0
  21. ndev/linux/commands/doctor.py +56 -0
  22. ndev/linux/commands/grok.py +75 -0
  23. ndev/linux/commands/install.py +39 -0
  24. ndev/linux/commands/list.py +47 -0
  25. ndev/linux/commands/logs.py +36 -0
  26. ndev/linux/commands/mailpit.py +82 -0
  27. ndev/linux/commands/reload.py +26 -0
  28. ndev/linux/commands/restart.py +34 -0
  29. ndev/linux/commands/setup.py +113 -0
  30. ndev/linux/commands/start.py +34 -0
  31. ndev/linux/commands/status.py +81 -0
  32. ndev/linux/commands/stop.py +34 -0
  33. ndev/linux/commands/uninstall.py +69 -0
  34. ndev/linux/commands/update.py +57 -0
  35. ndev/linux/commands/upgrade.py +81 -0
  36. ndev/linux/commands/use.py +108 -0
  37. ndev/linux/commands/vhost.py +350 -0
  38. ndev/linux/php/builder.py +183 -0
  39. ndev/linux/php/downloader.py +58 -0
  40. ndev/linux/php/extensions.py +146 -0
  41. ndev/linux/php/installer.py +42 -0
  42. ndev/linux/php/resolver.py +59 -0
  43. ndev/linux/php/templates.py +128 -0
  44. ndev/linux/runtime/fpm.py +117 -0
  45. ndev/linux/runtime/mailpit.py +244 -0
  46. ndev/linux/runtime/pma.py +223 -0
  47. ndev/linux/runtime/process.py +37 -0
  48. ndev/linux/runtime/sockets.py +16 -0
  49. ndev/linux/runtime/upgrade.py +431 -0
  50. ndev/linux/tui.py +1423 -0
  51. ndev/main.py +52 -0
  52. ndev/tui.py +23 -0
  53. ndev/win/__init__.py +1 -0
  54. ndev/win/cli.py +1898 -0
  55. ndev/win/commands/__init__.py +0 -0
  56. ndev/win/core/__init__.py +0 -0
  57. ndev/win/core/db.py +265 -0
  58. ndev/win/core/elevate.py +94 -0
  59. ndev/win/core/ext.py +241 -0
  60. ndev/win/core/fcgi.py +216 -0
  61. ndev/win/core/grok.py +55 -0
  62. ndev/win/core/logs.py +66 -0
  63. ndev/win/core/mailpit.py +236 -0
  64. ndev/win/core/mkcert.py +65 -0
  65. ndev/win/core/paths.py +85 -0
  66. ndev/win/core/php.py +533 -0
  67. ndev/win/core/pma.py +190 -0
  68. ndev/win/core/services.py +349 -0
  69. ndev/win/core/setup.py +361 -0
  70. ndev/win/core/upgrade.py +513 -0
  71. ndev/win/core/vhost.py +289 -0
  72. ndev/win/templates/vhost.conf.tmpl +33 -0
  73. ndev/win/templates/vhost_ssl.conf.tmpl +43 -0
  74. ndev/win/tui.py +1313 -0
  75. ndev_stack-0.1.0.dist-info/METADATA +553 -0
  76. ndev_stack-0.1.0.dist-info/RECORD +79 -0
  77. ndev_stack-0.1.0.dist-info/WHEEL +5 -0
  78. ndev_stack-0.1.0.dist-info/entry_points.txt +4 -0
  79. ndev_stack-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,146 @@
1
+ import shutil
2
+ import tarfile
3
+ import subprocess
4
+ import httpx
5
+ from pathlib import Path
6
+ from ndev.common.constants import PHP_DIR, DOWNLOADS_DIR, BUILDS_DIR
7
+ from ndev.linux.chroot.manager import SandboxManager
8
+ from ndev.common.logger import logger
9
+ from ndev.common.utils import run_command
10
+
11
+ def get_php_binaries(version: str) -> tuple[Path, Path, Path]:
12
+ """Get the path to php, phpize, and php-config binaries for a version."""
13
+ prefix = PHP_DIR / version
14
+ php_bin = prefix / "bin" / "php"
15
+ phpize_bin = prefix / "bin" / "phpize"
16
+ php_config_bin = prefix / "bin" / "php-config"
17
+ return php_bin, phpize_bin, php_config_bin
18
+
19
+ def list_extensions(version: str) -> list[str]:
20
+ """List loaded PHP extensions by running php -m."""
21
+ php_bin, _, _ = get_php_binaries(version)
22
+ if not php_bin.exists():
23
+ raise ValueError(f"PHP version {version} is not installed.")
24
+
25
+ res = subprocess.run([str(php_bin), "-m"], capture_output=True, text=True)
26
+ if res.returncode != 0:
27
+ raise ValueError(f"Failed to list extensions: {res.stderr}")
28
+
29
+ lines = res.stdout.splitlines()
30
+ extensions = []
31
+ for line in lines:
32
+ line = line.strip()
33
+ if line and not line.startswith("["):
34
+ extensions.append(line)
35
+ return sorted(extensions)
36
+
37
+ def enable_extension(version: str, ext_name: str):
38
+ """Enable an extension by creating its ini file in etc/conf.d/."""
39
+ prefix = PHP_DIR / version
40
+ conf_d = prefix / "etc" / "conf.d"
41
+ conf_d.mkdir(parents=True, exist_ok=True)
42
+
43
+ ini_file = conf_d / f"{ext_name}.ini"
44
+
45
+ # OPcache and Xdebug require zend_extension, others require extension
46
+ if ext_name.lower() in ["opcache", "xdebug"]:
47
+ ini_file.write_text(f"zend_extension={ext_name}\n")
48
+ else:
49
+ ini_file.write_text(f"extension={ext_name}\n")
50
+
51
+ logger.info(f"Extension '{ext_name}' enabled for PHP {version} (ini file created at {ini_file}).")
52
+
53
+ def disable_extension(version: str, ext_name: str):
54
+ """Disable an extension by removing its ini file in etc/conf.d/."""
55
+ prefix = PHP_DIR / version
56
+ ini_file = prefix / "etc" / "conf.d" / f"{ext_name}.ini"
57
+ if ini_file.exists():
58
+ ini_file.unlink()
59
+ logger.info(f"Extension '{ext_name}' disabled for PHP {version} (ini file removed).")
60
+ else:
61
+ logger.warning(f"Extension '{ext_name}' was not enabled (ini file {ini_file} does not exist).")
62
+
63
+ def install_extension(version: str, ext_name: str, show_logs: bool = False):
64
+ """Download, compile, and install a PECL extension inside the sandbox."""
65
+ php_bin, phpize_bin, php_config_bin = get_php_binaries(version)
66
+ if not php_bin.exists():
67
+ raise ValueError(f"PHP version {version} is not installed.")
68
+ if not phpize_bin.exists():
69
+ raise ValueError(f"phpize binary not found for version {version}. Development headers may be missing.")
70
+
71
+ # 1. Download extension archive on host
72
+ DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
73
+ archive_path = DOWNLOADS_DIR / f"{ext_name}.tgz"
74
+ url = f"https://pecl.php.net/get/{ext_name}"
75
+
76
+ logger.info(f"Downloading extension '{ext_name}' from {url}...")
77
+ with httpx.Client(follow_redirects=True) as client:
78
+ res = client.get(url)
79
+ if res.status_code != 200:
80
+ raise ValueError(f"Failed to download extension '{ext_name}' from PECL (HTTP status {res.status_code}).")
81
+ archive_path.write_bytes(res.content)
82
+
83
+ # 2. Extract extension archive
84
+ BUILDS_DIR.mkdir(parents=True, exist_ok=True)
85
+ build_dir = BUILDS_DIR / f"ext-{ext_name}"
86
+ if build_dir.exists():
87
+ if build_dir.is_dir():
88
+ shutil.rmtree(build_dir)
89
+ else:
90
+ build_dir.unlink()
91
+
92
+ logger.info(f"Extracting extension archive to {build_dir}...")
93
+ with tarfile.open(archive_path) as tar:
94
+ root_dir_name = None
95
+ for member in tar.getmembers():
96
+ if member.name.endswith("config.m4"):
97
+ parts = Path(member.name).parts
98
+ if len(parts) > 1:
99
+ root_dir_name = parts[0]
100
+ break
101
+ if not root_dir_name:
102
+ for member in tar.getmembers():
103
+ parts = Path(member.name).parts
104
+ if len(parts) > 1 and parts[0] != ".":
105
+ root_dir_name = parts[0]
106
+ break
107
+ if not root_dir_name:
108
+ raise ValueError("Could not find a valid root directory in extension archive.")
109
+ tar.extractall(path=BUILDS_DIR)
110
+
111
+ extracted_path = BUILDS_DIR / root_dir_name
112
+ if extracted_path.resolve() != build_dir.resolve():
113
+ shutil.move(str(extracted_path), str(build_dir))
114
+
115
+ # 3. Configure and compile inside sandbox
116
+ if not show_logs:
117
+ logger.info(f"[yellow]Compiling extension '{ext_name}'...[/yellow]")
118
+ else:
119
+ logger.info(f"Compiling extension '{ext_name}' inside the sandbox...")
120
+ sandbox = SandboxManager()
121
+
122
+ # Run phpize
123
+ sandbox.run([str(phpize_bin)], cwd=build_dir, show_logs=show_logs)
124
+
125
+ # Run configure
126
+ sandbox.run(["./configure", f"--with-php-config={php_config_bin}"], cwd=build_dir, show_logs=show_logs)
127
+
128
+ # Run make
129
+ sandbox.run(["make", "-j4"], cwd=build_dir, show_logs=show_logs)
130
+
131
+ if not show_logs:
132
+ logger.info(f"[green]Compiled extension '{ext_name}'[/green]")
133
+ logger.info(f"[yellow]Installing extension '{ext_name}'...[/yellow]")
134
+ else:
135
+ logger.info("Installing extension...")
136
+ sandbox.run(["make", "install"], cwd=build_dir, show_logs=show_logs)
137
+
138
+ if not show_logs:
139
+ logger.info(f"[green]Installed extension '{ext_name}'[/green]")
140
+
141
+ # 4. Enable extension (strip version suffix if present: e.g. xdebug-3.1.6 -> xdebug)
142
+ base_ext_name = ext_name.split("-")[0]
143
+ enable_extension(version, base_ext_name)
144
+
145
+ # Clean up build dir
146
+ shutil.rmtree(build_dir, ignore_errors=True)
@@ -0,0 +1,42 @@
1
+ from pathlib import Path
2
+ from ndev.linux.php.resolver import resolve_version
3
+ from ndev.linux.php.downloader import download_php_source
4
+ from ndev.linux.php.builder import build_php
5
+ from ndev.linux.php.templates import write_default_configs
6
+ from ndev.linux.chroot.packages import install_host_packages
7
+ from ndev.common.logger import logger
8
+
9
+ def install_version(version_input: str, show_logs: bool = False) -> str:
10
+ """Resolve, download, build and configure a PHP version."""
11
+ resolved_version, filename, sha256, download_url = resolve_version(version_input)
12
+
13
+ archive_path = download_php_source(filename, sha256, download_url)
14
+
15
+ # Pre-install development dependencies inside sandbox
16
+ install_host_packages([
17
+ "libsqlite3-dev",
18
+ "libonig-dev",
19
+ "libcrypt-dev",
20
+ "libcurl4-openssl-dev",
21
+ "libxml2-dev",
22
+ "libssl-dev",
23
+ "libzip-dev",
24
+ "libicu-dev",
25
+ "libsodium-dev",
26
+ "libpng-dev",
27
+ "libjpeg-dev",
28
+ "libwebp-dev",
29
+ "libwebpdecoder3",
30
+ "libfreetype-dev",
31
+ "libbz2-dev",
32
+ "libgmp-dev",
33
+ "libreadline-dev",
34
+ "zlib1g-dev"
35
+ ], show_logs=show_logs)
36
+
37
+ install_prefix = build_php(resolved_version, archive_path, show_logs=show_logs)
38
+
39
+ write_default_configs(install_prefix, resolved_version)
40
+
41
+ logger.info(f"Successfully finished installation of PHP version {resolved_version}")
42
+ return resolved_version
@@ -0,0 +1,59 @@
1
+ import httpx
2
+ from packaging.version import parse as parse_version
3
+ from ndev.common.github import fetch_releases
4
+ from ndev.common.logger import logger
5
+
6
+ def resolve_version(version_input: str) -> tuple[str, str, str, str]:
7
+ """
8
+ Resolve version input (e.g. '8.4', '8.5.8') to:
9
+ (exact_version, filename, sha256, download_url)
10
+ """
11
+ version_input = version_input.strip().lower()
12
+ if version_input.startswith("php-"):
13
+ version_input = version_input[4:]
14
+
15
+ parts = version_input.split(".")
16
+ try:
17
+ major = int(parts[0])
18
+ except ValueError:
19
+ raise ValueError(f"Invalid version format: '{version_input}'")
20
+
21
+ logger.info(f"Resolving version prefix '{version_input}'...")
22
+ releases = fetch_releases(major)
23
+ if not releases:
24
+ raise ValueError(f"No releases found or network error for PHP version {major}")
25
+
26
+ matching_versions = []
27
+ for v in releases.keys():
28
+ if v.startswith(version_input):
29
+ matching_versions.append(v)
30
+
31
+ if not matching_versions:
32
+ raise ValueError(f"Could not resolve version prefix '{version_input}' to any active release.")
33
+
34
+ resolved_version = max(matching_versions, key=parse_version)
35
+ logger.info(f"Resolved to version: {resolved_version}")
36
+
37
+ release_data = releases[resolved_version]
38
+ sources = release_data.get("source", [])
39
+
40
+ chosen_source = None
41
+ for ext in [".tar.xz", ".tar.gz", ".tar.bz2"]:
42
+ for src in sources:
43
+ if src.get("filename", "").endswith(ext):
44
+ chosen_source = src
45
+ break
46
+ if chosen_source:
47
+ break
48
+
49
+ if not chosen_source:
50
+ if sources:
51
+ chosen_source = sources[0]
52
+ else:
53
+ raise ValueError(f"No source downloads available for resolved version {resolved_version}")
54
+
55
+ filename = chosen_source["filename"]
56
+ sha256 = chosen_source.get("sha256", "")
57
+ download_url = f"https://www.php.net/distributions/{filename}"
58
+
59
+ return resolved_version, filename, sha256, download_url
@@ -0,0 +1,128 @@
1
+ import os
2
+ import getpass
3
+ from pathlib import Path
4
+ from jinja2 import Template
5
+ from ndev.common.constants import RUN_DIR, LOGS_DIR
6
+ from ndev.common.logger import logger
7
+
8
+ PHP_INI_TEMPLATE = """[PHP]
9
+ engine = On
10
+ short_open_tag = Off
11
+ precision = 14
12
+ output_buffering = 4096
13
+ zlib.output_compression = Off
14
+ implicit_flush = Off
15
+ unserialize_callback_func =
16
+ serialize_precision = -1
17
+ disable_functions =
18
+ disable_classes =
19
+ zend.enable_gc = On
20
+ expose_php = On
21
+ max_execution_time = 30
22
+ max_input_time = 60
23
+ memory_limit = 128M
24
+ error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
25
+ display_errors = On
26
+ display_startup_errors = On
27
+ log_errors = On
28
+ log_errors_max_len = 1024
29
+ ignore_repeated_errors = Off
30
+ ignore_repeated_source = Off
31
+ report_memleaks = On
32
+ html_errors = On
33
+ variables_order = "GPCS"
34
+ request_order = "GP"
35
+ register_argc_argv = Off
36
+ auto_globals_jit = On
37
+ post_max_size = 8M
38
+ default_mimetype = "text/html"
39
+ default_charset = "UTF-8"
40
+ doc_root =
41
+ user_dir =
42
+ enable_dl = Off
43
+ file_uploads = On
44
+ upload_max_filesize = 2M
45
+ max_file_uploads = 20
46
+ allow_url_fopen = On
47
+ allow_url_include = Off
48
+ default_socket_timeout = 60
49
+
50
+ [CLI Server]
51
+ cli_server.color = On
52
+
53
+ [Date]
54
+ date.timezone = UTC
55
+ """
56
+
57
+ PHP_FPM_CONF_TEMPLATE = """[global]
58
+ pid = {{ run_dir }}/php-fpm-{{ major_minor }}.pid
59
+ error_log = {{ logs_dir }}/php-fpm-{{ major_minor }}.log
60
+ log_level = notice
61
+ include = {{ prefix }}/etc/php-fpm.d/*.conf
62
+ """
63
+
64
+ WWW_CONF_TEMPLATE = """[www]
65
+ user = {{ user }}
66
+ group = {{ group }}
67
+ listen = {{ run_dir }}/php{{ major_minor }}.sock
68
+ listen.owner = {{ user }}
69
+ listen.group = {{ group }}
70
+ listen.mode = 0666
71
+
72
+ pm = dynamic
73
+ pm.max_children = 5
74
+ pm.start_servers = 2
75
+ pm.min_spare_servers = 1
76
+ pm.max_spare_servers = 3
77
+ """
78
+
79
+ def get_major_minor(version: str) -> str:
80
+ parts = version.split(".")
81
+ return f"{parts[0]}{parts[1]}"
82
+
83
+ def write_default_configs(prefix: Path, version: str):
84
+ """Generate and write default configs to the installed PHP prefix."""
85
+ etc_dir = prefix / "etc"
86
+ fpm_d = etc_dir / "php-fpm.d"
87
+ conf_d = etc_dir / "conf.d"
88
+
89
+ # Create directories
90
+ etc_dir.mkdir(parents=True, exist_ok=True)
91
+ fpm_d.mkdir(parents=True, exist_ok=True)
92
+ conf_d.mkdir(parents=True, exist_ok=True)
93
+
94
+ # Get current user details
95
+ user = getpass.getuser()
96
+ # On some systems, group matches user
97
+ group = user
98
+
99
+ major_minor = get_major_minor(version)
100
+
101
+ context = {
102
+ "prefix": str(prefix),
103
+ "run_dir": str(RUN_DIR),
104
+ "logs_dir": str(LOGS_DIR),
105
+ "major_minor": major_minor,
106
+ "user": user,
107
+ "group": group
108
+ }
109
+
110
+ # 1. php.ini
111
+ php_ini_path = etc_dir / "php.ini"
112
+ if not php_ini_path.exists():
113
+ php_ini_path.write_text(PHP_INI_TEMPLATE)
114
+ logger.info(f"Generated default php.ini at {php_ini_path}")
115
+
116
+ # 2. php-fpm.conf
117
+ php_fpm_conf_path = etc_dir / "php-fpm.conf"
118
+ if not php_fpm_conf_path.exists():
119
+ rendered = Template(PHP_FPM_CONF_TEMPLATE).render(context)
120
+ php_fpm_conf_path.write_text(rendered)
121
+ logger.info(f"Generated default php-fpm.conf at {php_fpm_conf_path}")
122
+
123
+ # 3. www.conf
124
+ www_conf_path = fpm_d / "www.conf"
125
+ if not www_conf_path.exists():
126
+ rendered = Template(WWW_CONF_TEMPLATE).render(context)
127
+ www_conf_path.write_text(rendered)
128
+ logger.info(f"Generated default www.conf at {www_conf_path}")
@@ -0,0 +1,117 @@
1
+ import time
2
+ import signal
3
+ from pathlib import Path
4
+ import subprocess
5
+ from ndev.common.constants import PHP_DIR
6
+ from ndev.common.logger import logger
7
+ from ndev.linux.runtime.process import is_pid_running, read_pid_file, kill_process
8
+ from ndev.linux.runtime.sockets import get_pid_path, get_socket_path
9
+
10
+ def get_fpm_binary(version: str) -> Path:
11
+ """Get the path to php-fpm binary for a version."""
12
+ prefix = PHP_DIR / version
13
+ return prefix / "sbin" / "php-fpm"
14
+
15
+ def start_fpm(version: str):
16
+ """Start PHP-FPM daemon for a version."""
17
+ pid_file = get_pid_path(version)
18
+ pid = read_pid_file(pid_file)
19
+
20
+ if pid and is_pid_running(pid):
21
+ logger.info(f"PHP-FPM {version} is already running with PID {pid}.")
22
+ return
23
+
24
+ fpm_bin = get_fpm_binary(version)
25
+ if not fpm_bin.exists():
26
+ raise FileNotFoundError(f"PHP-FPM binary not found at {fpm_bin} for version {version}")
27
+
28
+ prefix = PHP_DIR / version
29
+ conf_file = prefix / "etc" / "php-fpm.conf"
30
+ ini_file = prefix / "etc" / "php.ini"
31
+
32
+ cmd = [
33
+ str(fpm_bin),
34
+ "-y", str(conf_file),
35
+ "-c", str(ini_file)
36
+ ]
37
+
38
+ logger.info(f"Starting PHP-FPM {version}...")
39
+ res = subprocess.run(cmd, capture_output=True, text=True)
40
+ if res.returncode != 0:
41
+ raise RuntimeError(f"Failed to start PHP-FPM {version}: {res.stderr or res.stdout}")
42
+
43
+ for _ in range(10):
44
+ time.sleep(0.2)
45
+ pid = read_pid_file(pid_file)
46
+ if pid and is_pid_running(pid):
47
+ logger.info(f"PHP-FPM {version} started successfully (PID {pid}).")
48
+ return
49
+
50
+ logger.warning(f"PHP-FPM {version} launched, but PID file could not be verified.")
51
+
52
+ def stop_fpm(version: str):
53
+ """Stop PHP-FPM daemon for a version."""
54
+ pid_file = get_pid_path(version)
55
+ pid = read_pid_file(pid_file)
56
+
57
+ if not pid or not is_pid_running(pid):
58
+ logger.info(f"PHP-FPM {version} is not running.")
59
+ if pid_file.exists():
60
+ pid_file.unlink()
61
+ return
62
+
63
+ logger.info(f"Stopping PHP-FPM {version} (PID {pid})...")
64
+ kill_process(pid, signal.SIGTERM)
65
+
66
+ for _ in range(20):
67
+ time.sleep(0.2)
68
+ if not is_pid_running(pid):
69
+ logger.info(f"PHP-FPM {version} stopped.")
70
+ if pid_file.exists():
71
+ pid_file.unlink()
72
+ sock_file = get_socket_path(version)
73
+ if sock_file.exists():
74
+ sock_file.unlink()
75
+ return
76
+
77
+ logger.warning("FPM process did not exit. Force killing...")
78
+ kill_process(pid, signal.SIGKILL)
79
+ if pid_file.exists():
80
+ pid_file.unlink()
81
+ sock_file = get_socket_path(version)
82
+ if sock_file.exists():
83
+ sock_file.unlink()
84
+
85
+ def reload_fpm(version: str):
86
+ """Gracefully reload PHP-FPM daemon (SIGUSR2)."""
87
+ pid_file = get_pid_path(version)
88
+ pid = read_pid_file(pid_file)
89
+
90
+ if not pid or not is_pid_running(pid):
91
+ logger.warning(f"PHP-FPM {version} is not running. Starting it instead...")
92
+ start_fpm(version)
93
+ return
94
+
95
+ logger.info(f"Reloading PHP-FPM {version} (PID {pid})...")
96
+ kill_process(pid, signal.SIGUSR2)
97
+ logger.info("SIGUSR2 reload signal sent.")
98
+
99
+ def restart_fpm(version: str):
100
+ """Restart PHP-FPM daemon."""
101
+ stop_fpm(version)
102
+ start_fpm(version)
103
+
104
+ def get_fpm_status(version: str) -> dict:
105
+ """Get the status of PHP-FPM for a version."""
106
+ pid_file = get_pid_path(version)
107
+ pid = read_pid_file(pid_file)
108
+ running = is_pid_running(pid) if pid else False
109
+ socket_path = get_socket_path(version)
110
+
111
+ return {
112
+ "version": version,
113
+ "pid": pid if running else None,
114
+ "running": running,
115
+ "socket": str(socket_path) if socket_path.exists() else str(socket_path),
116
+ "socket_exists": socket_path.exists()
117
+ }