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,244 @@
1
+ """
2
+ Mailpit management for Linux ndev.
3
+ Downloads prebuilt Linux amd64/arm64 binaries from GitHub releases and manages
4
+ the background daemon.
5
+ """
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import tarfile
12
+ import time
13
+ import webbrowser
14
+ import httpx
15
+ from pathlib import Path
16
+ from rich.console import Console
17
+ from rich.progress import Progress, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn, SpinnerColumn
18
+
19
+ from ndev.common.constants import NDEV_DIR, RUN_DIR, LOGS_DIR
20
+ from ndev.common.logger import logger
21
+ from ndev.linux.runtime.process import is_pid_running, read_pid_file, kill_process
22
+
23
+ console = Console()
24
+
25
+ GITHUB_REPO = "axllent/mailpit"
26
+ RELEASES_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
27
+
28
+ BIN_DIR = NDEV_DIR / "bin"
29
+ MAILPIT_BIN = BIN_DIR / "mailpit"
30
+ MAILPIT_PID_FILE = RUN_DIR / "mailpit.pid"
31
+ MAILPIT_PORT_FILE = RUN_DIR / "mailpit.port"
32
+ MAILPIT_LOG_FILE = LOGS_DIR / "mailpit.log"
33
+ MAILPIT_DB_FILE = NDEV_DIR / "mailpit.db"
34
+
35
+ DEFAULT_SMTP_PORT = 1025
36
+ DEFAULT_WEB_PORT = 8025
37
+
38
+
39
+ def is_installed() -> bool:
40
+ """Check if mailpit binary is present."""
41
+ return MAILPIT_BIN.exists() or bool(shutil.which("mailpit"))
42
+
43
+
44
+ def get_binary_path() -> Path:
45
+ if MAILPIT_BIN.exists():
46
+ return MAILPIT_BIN
47
+ system_bin = shutil.which("mailpit")
48
+ if system_bin:
49
+ return Path(system_bin)
50
+ raise FileNotFoundError("Mailpit binary not found. Run `ndev mailpit install` first.")
51
+
52
+
53
+ def _get_arch_asset_name() -> str:
54
+ machine = platform.machine().lower()
55
+ if machine in ["x86_64", "amd64"]:
56
+ return "mailpit-linux-amd64.tar.gz"
57
+ elif machine in ["aarch64", "arm64"]:
58
+ return "mailpit-linux-arm64.tar.gz"
59
+ elif machine in ["i386", "i686"]:
60
+ return "mailpit-linux-386.tar.gz"
61
+ elif "arm" in machine:
62
+ return "mailpit-linux-arm.tar.gz"
63
+ return "mailpit-linux-amd64.tar.gz"
64
+
65
+
66
+ def setup_mailpit():
67
+ """Download and set up prebuilt Mailpit binary if missing."""
68
+ if is_installed():
69
+ return
70
+
71
+ console.print("[bold yellow]Mailpit is not installed. Downloading prebuilt binary...[/bold yellow]")
72
+ BIN_DIR.mkdir(parents=True, exist_ok=True)
73
+ temp_dir = NDEV_DIR / "mailpit_temp"
74
+ tar_path = NDEV_DIR / "mailpit.tar.gz"
75
+
76
+ try:
77
+ # Fetch release metadata
78
+ with httpx.Client(timeout=30.0) as client:
79
+ resp = client.get(RELEASES_API_URL, headers={"User-Agent": "ndev/0.1.0"})
80
+ if resp.status_code != 200:
81
+ raise RuntimeError(f"Failed to fetch Mailpit releases. HTTP {resp.status_code}")
82
+ release_data = resp.json()
83
+
84
+ target_asset = _get_arch_asset_name()
85
+ download_url = None
86
+ for asset in release_data.get("assets", []):
87
+ if asset.get("name") == target_asset:
88
+ download_url = asset.get("browser_download_url")
89
+ break
90
+
91
+ if not download_url:
92
+ raise RuntimeError(f"Could not find asset '{target_asset}' in Mailpit {release_data.get('tag_name')}")
93
+
94
+ # Download tarball
95
+ with tar_path.open("wb") as f:
96
+ with httpx.stream("GET", download_url, follow_redirects=True, timeout=60.0) as r:
97
+ if r.status_code != 200:
98
+ raise RuntimeError(f"Failed to download Mailpit: HTTP {r.status_code}")
99
+ total = int(r.headers.get("Content-Length", 0))
100
+ with Progress(
101
+ TextColumn("[bold blue]{task.description}"),
102
+ BarColumn(),
103
+ DownloadColumn(),
104
+ TransferSpeedColumn(),
105
+ TimeRemainingColumn(),
106
+ console=console,
107
+ ) as progress:
108
+ task = progress.add_task("Downloading Mailpit...", total=total)
109
+ for chunk in r.iter_bytes(chunk_size=16384):
110
+ f.write(chunk)
111
+ progress.update(task, advance=len(chunk))
112
+
113
+ if temp_dir.exists():
114
+ shutil.rmtree(temp_dir)
115
+ temp_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ with tarfile.open(tar_path, "r:gz") as tf:
118
+ tf.extractall(temp_dir)
119
+
120
+ extracted_bin = temp_dir / "mailpit"
121
+ if not extracted_bin.exists():
122
+ raise RuntimeError("mailpit binary not found in archive")
123
+
124
+ shutil.move(str(extracted_bin), str(MAILPIT_BIN))
125
+ MAILPIT_BIN.chmod(0o755)
126
+ console.print("[bold green]Mailpit installed successfully![/bold green]\n")
127
+
128
+ except Exception as e:
129
+ logger.error(f"Failed to install Mailpit: {e}")
130
+ if MAILPIT_BIN.exists():
131
+ MAILPIT_BIN.unlink()
132
+ raise RuntimeError(f"Mailpit setup failed: {e}")
133
+ finally:
134
+ if temp_dir.exists():
135
+ shutil.rmtree(temp_dir)
136
+ if tar_path.exists():
137
+ tar_path.unlink()
138
+
139
+
140
+ def start_mailpit(smtp_port: int = DEFAULT_SMTP_PORT, web_port: int = DEFAULT_WEB_PORT):
141
+ """Start Mailpit background service."""
142
+ pid = read_pid_file(MAILPIT_PID_FILE)
143
+ if pid and is_pid_running(pid):
144
+ existing_port = MAILPIT_PORT_FILE.read_text().strip() if MAILPIT_PORT_FILE.exists() else str(DEFAULT_WEB_PORT)
145
+ logger.info(f"Mailpit service is already running (PID {pid}, Web UI http://127.0.0.1:{existing_port}).")
146
+ return
147
+
148
+ setup_mailpit()
149
+ bin_path = get_binary_path()
150
+
151
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
152
+ RUN_DIR.mkdir(parents=True, exist_ok=True)
153
+
154
+ log_fd = open(MAILPIT_LOG_FILE, "a")
155
+
156
+ cmd = [
157
+ str(bin_path),
158
+ "--smtp", f"127.0.0.1:{smtp_port}",
159
+ "--listen", f"127.0.0.1:{web_port}",
160
+ "--database", str(MAILPIT_DB_FILE),
161
+ ]
162
+
163
+ proc = subprocess.Popen(
164
+ cmd,
165
+ stdout=log_fd,
166
+ stderr=log_fd,
167
+ start_new_session=True,
168
+ )
169
+
170
+ time.sleep(0.5)
171
+ if proc.poll() is not None:
172
+ raise RuntimeError(f"Mailpit exited immediately. Check {MAILPIT_LOG_FILE} for details.")
173
+
174
+ MAILPIT_PID_FILE.write_text(str(proc.pid))
175
+ MAILPIT_PORT_FILE.write_text(f"{smtp_port}:{web_port}")
176
+
177
+ logger.info(f"Mailpit service started on http://127.0.0.1:{web_port} (SMTP: 127.0.0.1:{smtp_port}, PID {proc.pid})")
178
+
179
+
180
+ def stop_mailpit():
181
+ """Stop Mailpit service."""
182
+ pid = read_pid_file(MAILPIT_PID_FILE)
183
+ if not pid or not is_pid_running(pid):
184
+ logger.info("Mailpit service is not running.")
185
+ if MAILPIT_PID_FILE.exists():
186
+ MAILPIT_PID_FILE.unlink()
187
+ return
188
+
189
+ logger.info(f"Stopping Mailpit service (PID {pid})...")
190
+ kill_process(pid)
191
+ if MAILPIT_PID_FILE.exists():
192
+ MAILPIT_PID_FILE.unlink()
193
+ logger.info("Mailpit service stopped.")
194
+
195
+
196
+ def restart_mailpit(smtp_port: int = DEFAULT_SMTP_PORT, web_port: int = DEFAULT_WEB_PORT):
197
+ """Restart Mailpit service."""
198
+ stop_mailpit()
199
+ time.sleep(0.5)
200
+ start_mailpit(smtp_port=smtp_port, web_port=web_port)
201
+
202
+
203
+ def get_mailpit_status() -> dict:
204
+ """Get status details for Mailpit service."""
205
+ pid = read_pid_file(MAILPIT_PID_FILE)
206
+ running = is_pid_running(pid) if pid else False
207
+ smtp_port = DEFAULT_SMTP_PORT
208
+ web_port = DEFAULT_WEB_PORT
209
+
210
+ if MAILPIT_PORT_FILE.exists():
211
+ try:
212
+ val = MAILPIT_PORT_FILE.read_text().strip()
213
+ if ":" in val:
214
+ s, w = val.split(":", 1)
215
+ smtp_port = int(s)
216
+ web_port = int(w)
217
+ else:
218
+ web_port = int(val)
219
+ except Exception:
220
+ pass
221
+
222
+ installed = is_installed()
223
+
224
+ return {
225
+ "service": "mailpit",
226
+ "running": running,
227
+ "pid": pid if running else None,
228
+ "smtp_port": smtp_port,
229
+ "web_port": web_port,
230
+ "url": f"http://127.0.0.1:{web_port}" if running else None,
231
+ "smtp": f"127.0.0.1:{smtp_port}",
232
+ "installed": installed,
233
+ }
234
+
235
+
236
+ def launch_mailpit():
237
+ """Open Mailpit web UI in default browser, starting it if stopped."""
238
+ status = get_mailpit_status()
239
+ if not status["running"]:
240
+ start_mailpit()
241
+ status = get_mailpit_status()
242
+ url = status["url"] or f"http://127.0.0.1:{DEFAULT_WEB_PORT}"
243
+ console.print(f"Opening [bold cyan]{url}[/bold cyan] in default browser...")
244
+ webbrowser.open(url)
@@ -0,0 +1,223 @@
1
+ import os
2
+ import shutil
3
+ import socket
4
+ import subprocess
5
+ import zipfile
6
+ import secrets
7
+ import httpx
8
+ from pathlib import Path
9
+ from rich.console import Console
10
+ from rich.progress import Progress, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn, SpinnerColumn
11
+ from ndev.common.constants import NDEV_DIR, CURRENT_LINK, RUN_DIR, LOGS_DIR
12
+ from ndev.common.logger import logger
13
+ from ndev.linux.runtime.process import is_pid_running, read_pid_file, kill_process
14
+
15
+ PMA_DIR = NDEV_DIR / "pma"
16
+ # Backward compatibility migration for legacy phpmyadmin directory
17
+ if not PMA_DIR.exists() and (NDEV_DIR / "phpmyadmin").exists():
18
+ try:
19
+ (NDEV_DIR / "phpmyadmin").rename(PMA_DIR)
20
+ except Exception:
21
+ PMA_DIR = NDEV_DIR / "phpmyadmin"
22
+
23
+ PMA_PID_FILE = RUN_DIR / "pma.pid"
24
+ PMA_PORT_FILE = RUN_DIR / "pma.port"
25
+ PMA_LOG_FILE = LOGS_DIR / "pma.log"
26
+
27
+ def download_file(url: str, dest_path: Path):
28
+ with dest_path.open("wb") as f:
29
+ with httpx.stream("GET", url, follow_redirects=True) as r:
30
+ if r.status_code != 200:
31
+ raise RuntimeError(f"Failed to download phpMyAdmin. HTTP Status Code: {r.status_code}")
32
+
33
+ total = int(r.headers.get("Content-Length", 0))
34
+
35
+ with Progress(
36
+ TextColumn("[bold blue]{task.description}"),
37
+ BarColumn(),
38
+ DownloadColumn(),
39
+ TransferSpeedColumn(),
40
+ TimeRemainingColumn(),
41
+ console=console
42
+ ) as progress:
43
+ task = progress.add_task("Downloading phpMyAdmin...", total=total)
44
+ for chunk in r.iter_bytes(chunk_size=16384):
45
+ f.write(chunk)
46
+ progress.update(task, advance=len(chunk))
47
+
48
+ def extract_zip(zip_path: Path, extract_dir: Path):
49
+ with Progress(
50
+ SpinnerColumn(),
51
+ TextColumn("[bold blue]{task.description}"),
52
+ console=console
53
+ ) as progress:
54
+ task = progress.add_task("Extracting phpMyAdmin...", total=None)
55
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
56
+ zip_ref.extractall(extract_dir)
57
+
58
+ def find_free_port(start_port=8080) -> int:
59
+ port = start_port
60
+ while port < 65535:
61
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
62
+ try:
63
+ s.bind(("127.0.0.1", port))
64
+ return port
65
+ except socket.error:
66
+ port += 1
67
+ raise RuntimeError("No free ports found.")
68
+
69
+ def setup_pma():
70
+ """Download and set up phpMyAdmin if not already setup."""
71
+ if PMA_DIR.exists() and (PMA_DIR / "index.php").exists():
72
+ return
73
+
74
+ console.print("[bold yellow]phpMyAdmin is not set up. Installing now...[/bold yellow]")
75
+ temp_dir = NDEV_DIR / "pma_temp"
76
+ zip_path = NDEV_DIR / "phpmyadmin.zip"
77
+
78
+ try:
79
+ download_file("https://www.phpmyadmin.net/downloads/phpMyAdmin-latest-all-languages.zip", zip_path)
80
+
81
+ if temp_dir.exists():
82
+ shutil.rmtree(temp_dir)
83
+ temp_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ extract_zip(zip_path, temp_dir)
86
+
87
+ subfolders = list(temp_dir.glob("phpMyAdmin-*"))
88
+ if not subfolders:
89
+ raise RuntimeError("Could not find extracted phpMyAdmin folder inside zip.")
90
+ src_folder = subfolders[0]
91
+
92
+ if PMA_DIR.exists():
93
+ shutil.rmtree(PMA_DIR)
94
+ PMA_DIR.mkdir(parents=True, exist_ok=True)
95
+
96
+ for item in src_folder.iterdir():
97
+ shutil.move(str(item), str(PMA_DIR / item.name))
98
+
99
+ config_path = PMA_DIR / "config.inc.php"
100
+ blowfish_secret = secrets.token_hex(16)
101
+ config_content = f"""<?php
102
+ $cfg['blowfish_secret'] = '{blowfish_secret}';
103
+ $i = 0;
104
+ $i++;
105
+ $cfg['Servers'][$i]['auth_type'] = 'cookie';
106
+ $cfg['Servers'][$i]['host'] = '127.0.0.1';
107
+ $cfg['Servers'][$i]['compress'] = false;
108
+ $cfg['Servers'][$i]['AllowNoPassword'] = true;
109
+ """
110
+ config_path.write_text(config_content)
111
+ console.print("[bold green]phpMyAdmin setup completed successfully![/bold green]\n")
112
+ except Exception as e:
113
+ logger.error(f"Failed to set up phpMyAdmin: {e}")
114
+ if temp_dir.exists():
115
+ shutil.rmtree(temp_dir)
116
+ if zip_path.exists():
117
+ zip_path.unlink()
118
+ if PMA_DIR.exists():
119
+ shutil.rmtree(PMA_DIR)
120
+ raise RuntimeError(f"phpMyAdmin setup failed: {e}")
121
+ finally:
122
+ if temp_dir.exists():
123
+ shutil.rmtree(temp_dir)
124
+ if zip_path.exists():
125
+ zip_path.unlink()
126
+
127
+ def start_pma(port: int = None):
128
+ """Start phpMyAdmin service in background."""
129
+ pid = read_pid_file(PMA_PID_FILE)
130
+ if pid and is_pid_running(pid):
131
+ existing_port = None
132
+ if PMA_PORT_FILE.exists():
133
+ existing_port = PMA_PORT_FILE.read_text().strip()
134
+ logger.info(f"phpMyAdmin service is already running (PID {pid}, Port {existing_port or 'unknown'}).")
135
+ return
136
+
137
+ php_path = CURRENT_LINK / "bin" / "php"
138
+ if not php_path.exists():
139
+ system_php = shutil.which("php")
140
+ if system_php:
141
+ php_path = Path(system_php)
142
+ else:
143
+ raise RuntimeError("No active PHP version found. Please run `ndev use <version>` or install PHP first.")
144
+
145
+ setup_pma()
146
+
147
+ if not port:
148
+ if PMA_PORT_FILE.exists():
149
+ try:
150
+ cached_port = int(PMA_PORT_FILE.read_text().strip())
151
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
152
+ s.bind(("127.0.0.1", cached_port))
153
+ port = cached_port
154
+ except Exception:
155
+ port = None
156
+ if not port:
157
+ port = find_free_port(8080)
158
+
159
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
160
+ RUN_DIR.mkdir(parents=True, exist_ok=True)
161
+
162
+ log_fd = open(PMA_LOG_FILE, "a")
163
+
164
+ cmd = [
165
+ str(php_path),
166
+ "-S", f"127.0.0.1:{port}",
167
+ "-t", str(PMA_DIR)
168
+ ]
169
+
170
+ proc = subprocess.Popen(
171
+ cmd,
172
+ cwd=PMA_DIR,
173
+ stdout=log_fd,
174
+ stderr=log_fd,
175
+ start_new_session=True
176
+ )
177
+
178
+ PMA_PID_FILE.write_text(str(proc.pid))
179
+ PMA_PORT_FILE.write_text(str(port))
180
+
181
+ logger.info(f"phpMyAdmin service started on http://127.0.0.1:{port} (PID {proc.pid})")
182
+
183
+ def stop_pma():
184
+ """Stop phpMyAdmin service."""
185
+ pid = read_pid_file(PMA_PID_FILE)
186
+ if not pid or not is_pid_running(pid):
187
+ logger.info("phpMyAdmin service is not running.")
188
+ if PMA_PID_FILE.exists():
189
+ PMA_PID_FILE.unlink()
190
+ return
191
+
192
+ logger.info(f"Stopping phpMyAdmin service (PID {pid})...")
193
+ kill_process(pid)
194
+ if PMA_PID_FILE.exists():
195
+ PMA_PID_FILE.unlink()
196
+ logger.info("phpMyAdmin service stopped.")
197
+
198
+ def restart_pma(port: int = None):
199
+ """Restart phpMyAdmin service."""
200
+ stop_pma()
201
+ start_pma(port=port)
202
+
203
+ def get_pma_status() -> dict:
204
+ """Get status details for phpMyAdmin service."""
205
+ pid = read_pid_file(PMA_PID_FILE)
206
+ running = is_pid_running(pid) if pid else False
207
+ port = None
208
+ if PMA_PORT_FILE.exists():
209
+ try:
210
+ port = int(PMA_PORT_FILE.read_text().strip())
211
+ except Exception:
212
+ pass
213
+
214
+ installed = PMA_DIR.exists() and (PMA_DIR / "index.php").exists()
215
+
216
+ return {
217
+ "service": "pma",
218
+ "running": running,
219
+ "pid": pid if running else None,
220
+ "port": port,
221
+ "url": f"http://127.0.0.1:{port}" if port and running else None,
222
+ "installed": installed
223
+ }
@@ -0,0 +1,37 @@
1
+ import os
2
+ import signal
3
+ from pathlib import Path
4
+ from ndev.common.logger import logger
5
+
6
+ def is_pid_running(pid: int) -> bool:
7
+ """Check if a process is running on POSIX systems."""
8
+ if pid <= 0:
9
+ return False
10
+ try:
11
+ os.kill(pid, 0)
12
+ return True
13
+ except OSError:
14
+ return False
15
+
16
+ def read_pid_file(pid_file: Path) -> int | None:
17
+ """Read a PID from a file."""
18
+ if not pid_file.exists():
19
+ return None
20
+ try:
21
+ content = pid_file.read_text().strip()
22
+ if content.isdigit():
23
+ return int(content)
24
+ except Exception:
25
+ pass
26
+ return None
27
+
28
+ def kill_process(pid: int, sig=signal.SIGTERM) -> bool:
29
+ """Send signal to process. Return True if successful."""
30
+ try:
31
+ os.kill(pid, sig)
32
+ return True
33
+ except ProcessLookupError:
34
+ return False
35
+ except PermissionError as e:
36
+ logger.error(f"Permission denied to signal process {pid}: {e}")
37
+ return False
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+ from ndev.common.constants import RUN_DIR
3
+
4
+ def get_major_minor(version: str) -> str:
5
+ parts = version.split(".")
6
+ return f"{parts[0]}{parts[1]}"
7
+
8
+ def get_socket_path(version: str) -> Path:
9
+ """Return the UNIX socket path for a PHP version."""
10
+ mm = get_major_minor(version)
11
+ return RUN_DIR / f"php{mm}.sock"
12
+
13
+ def get_pid_path(version: str) -> Path:
14
+ """Return the PID file path for a PHP-FPM daemon version."""
15
+ mm = get_major_minor(version)
16
+ return RUN_DIR / f"php-fpm-{mm}.pid"