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
ndev/win/core/pma.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ phpMyAdmin management as a background service using PHP's built-in web server.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ import urllib.request
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from . import fcgi, paths, php
15
+
16
+ DOWNLOAD_URL_TMPL = "https://files.phpmyadmin.net/phpMyAdmin/{version}/phpMyAdmin-{version}-english.zip"
17
+ DEFAULT_VERSION = "5.2.3"
18
+ DEFAULT_PORT = 8080
19
+
20
+ PMA_DIR = paths.PMA_DIR
21
+ _STATE_FILE = paths.RUN_DIR / "pma.json"
22
+
23
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
24
+ CREATE_NO_WINDOW = 0x08000000
25
+
26
+
27
+ def install(version: str = DEFAULT_VERSION) -> Path:
28
+ paths.ensure_dirs()
29
+ url = DOWNLOAD_URL_TMPL.format(version=version)
30
+ zip_path = paths.DOWNLOADS_DIR / f"phpMyAdmin-{version}.zip"
31
+ if not zip_path.exists():
32
+ req = urllib.request.Request(url, headers={"User-Agent": "ndev/0.1.0"})
33
+ tmp = zip_path.with_suffix(".part")
34
+ with urllib.request.urlopen(req, timeout=180) as resp, open(tmp, "wb") as f:
35
+ shutil.copyfileobj(resp, f)
36
+ tmp.rename(zip_path)
37
+
38
+ if PMA_DIR.exists():
39
+ shutil.rmtree(PMA_DIR)
40
+ PMA_DIR.mkdir(parents=True)
41
+
42
+ extract_tmp = paths.DOWNLOADS_DIR / "_extract_pma"
43
+ if extract_tmp.exists():
44
+ shutil.rmtree(extract_tmp)
45
+
46
+ with zipfile.ZipFile(zip_path) as zf:
47
+ zf.extractall(extract_tmp)
48
+
49
+ # Move inner extracted dir contents to PMA_DIR
50
+ inner_dirs = [d for d in extract_tmp.iterdir() if d.is_dir()]
51
+ if inner_dirs:
52
+ for item in inner_dirs[0].iterdir():
53
+ shutil.move(str(item), str(PMA_DIR / item.name))
54
+ else:
55
+ for item in extract_tmp.iterdir():
56
+ shutil.move(str(item), str(PMA_DIR / item.name))
57
+
58
+ shutil.rmtree(extract_tmp, ignore_errors=True)
59
+ _write_config()
60
+ return PMA_DIR
61
+
62
+
63
+ def _get_or_create_blowfish_secret() -> str:
64
+ import re, secrets
65
+ cfg_file = PMA_DIR / "config.inc.php"
66
+ if cfg_file.exists():
67
+ try:
68
+ content = cfg_file.read_text(encoding="utf-8", errors="ignore")
69
+ m = re.search(r"\$cfg\['blowfish_secret'\]\s*=\s*'([^']+)'", content)
70
+ if m and len(m.group(1)) >= 16:
71
+ return m.group(1)
72
+ except Exception:
73
+ pass
74
+ return secrets.token_hex(16)
75
+
76
+
77
+ def _write_config(mariadb_port: int = 3306) -> None:
78
+ secret = _get_or_create_blowfish_secret()
79
+ tmp_dir = PMA_DIR / "tmp"
80
+ tmp_dir.mkdir(parents=True, exist_ok=True)
81
+ clean_tmp = str(tmp_dir.resolve()).replace("\\", "/")
82
+ config = f"""<?php
83
+ $cfg['blowfish_secret'] = '{secret}';
84
+ $i = 0;
85
+ $i++;
86
+ $cfg['Servers'][$i]['auth_type'] = 'cookie';
87
+ $cfg['Servers'][$i]['host'] = '127.0.0.1';
88
+ $cfg['Servers'][$i]['port'] = {mariadb_port};
89
+ $cfg['Servers'][$i]['compress'] = false;
90
+ $cfg['Servers'][$i]['AllowNoPassword'] = true;
91
+ $cfg['TempDir'] = '{clean_tmp}';
92
+ $cfg['SendErrorReports'] = 'never';
93
+ $cfg['ShowPhpInfo'] = true;
94
+ $cfg['MaxRows'] = 50;
95
+ $cfg['UploadDir'] = '';
96
+ $cfg['SaveDir'] = '';
97
+ """
98
+ (PMA_DIR / "config.inc.php").write_text(config, encoding="utf-8")
99
+
100
+
101
+ def start(php_version: Optional[str] = None, port: int = DEFAULT_PORT) -> int:
102
+ if not PMA_DIR.exists() or not (PMA_DIR / "index.php").exists():
103
+ install()
104
+
105
+ # Check if already running
106
+ st = status()
107
+ if st:
108
+ raise RuntimeError(f"phpMyAdmin is already running at http://127.0.0.1:{st['port']} (PID {st['pid']})")
109
+
110
+ # Check if target port is in use
111
+ import socket
112
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
113
+ s.settimeout(0.5)
114
+ if s.connect_ex(("127.0.0.1", port)) == 0:
115
+ raise RuntimeError(f"Port {port} is already in use by another application.")
116
+
117
+ version = php_version or paths.get_current_version()
118
+ if not version:
119
+ installed = php.list_installed()
120
+ if installed:
121
+ version = installed[-1]
122
+ else:
123
+ raise RuntimeError("No PHP version installed -- run `ndev install <version>` first")
124
+
125
+ exe = php.php_exe(version)
126
+ if not exe.exists():
127
+ raise FileNotFoundError(f"PHP binary not found for version {version} at {exe}")
128
+
129
+ proc = subprocess.Popen(
130
+ [str(exe), "-S", f"127.0.0.1:{port}", "-t", str(PMA_DIR)],
131
+ creationflags=CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW,
132
+ )
133
+ paths.ensure_dirs()
134
+ state_data = {"pid": proc.pid, "port": port, "php_version": version, "url": f"http://127.0.0.1:{port}"}
135
+ _STATE_FILE.write_text(json.dumps(state_data, indent=2), encoding="utf-8")
136
+ return proc.pid
137
+
138
+
139
+ def stop() -> None:
140
+ import ctypes
141
+ if not _STATE_FILE.exists():
142
+ return
143
+ try:
144
+ state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
145
+ pid = state.get("pid")
146
+ if pid and fcgi.is_pid_alive(pid, expected_name="php"):
147
+ terminated = False
148
+ try:
149
+ handle = ctypes.windll.kernel32.OpenProcess(0x0001, False, pid)
150
+ if handle:
151
+ ctypes.windll.kernel32.TerminateProcess(handle, 0)
152
+ ctypes.windll.kernel32.CloseHandle(handle)
153
+ terminated = True
154
+ except Exception:
155
+ pass
156
+ if not terminated or fcgi.is_pid_alive(pid, expected_name="php"):
157
+ try:
158
+ subprocess.run(["taskkill.exe", "/F", "/PID", str(pid), "/T"], capture_output=True)
159
+ except Exception:
160
+ pass
161
+ except Exception:
162
+ pass
163
+ _STATE_FILE.unlink(missing_ok=True)
164
+
165
+
166
+ def status() -> dict | None:
167
+ if not _STATE_FILE.exists():
168
+ return None
169
+ try:
170
+ state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
171
+ pid = state.get("pid")
172
+ if pid and fcgi.is_pid_alive(pid):
173
+ return state
174
+ except Exception:
175
+ pass
176
+ _STATE_FILE.unlink(missing_ok=True)
177
+ return None
178
+
179
+
180
+ def restart(php_version: Optional[str] = None, port: int = DEFAULT_PORT) -> int:
181
+ import socket as _socket, time as _time
182
+ stop()
183
+ # Wait up to 3 s for the port to be released before attempting start
184
+ for _ in range(6):
185
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
186
+ s.settimeout(0.3)
187
+ if s.connect_ex(("127.0.0.1", port)) != 0:
188
+ break # port is free
189
+ _time.sleep(0.5)
190
+ return start(php_version, port)
@@ -0,0 +1,349 @@
1
+ """
2
+ Process and service control for Nginx and MariaDB.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ import json
8
+ import os
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from . import fcgi, paths
14
+
15
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
16
+ CREATE_NO_WINDOW = 0x08000000
17
+ PROCESS_TERMINATE = 0x0001
18
+
19
+
20
+ # ---- Nginx ------------------------------------------------------------------
21
+
22
+ def nginx_exe() -> Path:
23
+ return paths.NGINX_DIR / "nginx.exe"
24
+
25
+
26
+ def nginx_is_installed() -> bool:
27
+ return nginx_exe().exists()
28
+
29
+
30
+ def nginx_is_running() -> bool:
31
+ if not nginx_is_installed():
32
+ return False
33
+ pid_file = paths.NGINX_LOGS_DIR / "nginx.pid"
34
+ if pid_file.exists():
35
+ try:
36
+ pid = int(pid_file.read_text().strip())
37
+ if fcgi.is_pid_alive(pid):
38
+ return True
39
+ except Exception:
40
+ pass
41
+ # Fallback to tasklist check
42
+ try:
43
+ out = subprocess.run(
44
+ ["tasklist.exe", "/FI", "IMAGENAME eq nginx.exe", "/NH"],
45
+ capture_output=True, text=True
46
+ ).stdout
47
+ return "nginx.exe" in out.lower()
48
+ except Exception:
49
+ return False
50
+
51
+
52
+ def _wait_for_port(port: int, timeout: float = 3.0) -> bool:
53
+ """Poll TCP port until accepting connections or timeout occurs."""
54
+ import socket, time
55
+ deadline = time.time() + timeout
56
+ while time.time() < deadline:
57
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
58
+ s.settimeout(0.1)
59
+ if s.connect_ex(("127.0.0.1", port)) == 0:
60
+ return True
61
+ time.sleep(0.05)
62
+ return False
63
+
64
+
65
+ def _wait_for_port_closed(port: int, timeout: float = 1.5) -> bool:
66
+ """Poll TCP port until it is closed or timeout occurs."""
67
+ import socket, time
68
+ deadline = time.time() + timeout
69
+ while time.time() < deadline:
70
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
71
+ s.settimeout(0.1)
72
+ if s.connect_ex(("127.0.0.1", port)) != 0:
73
+ return True
74
+ time.sleep(0.05)
75
+ return False
76
+
77
+
78
+ def _find_port_owner(port: int) -> tuple[int | None, str | None]:
79
+ """Find PID and process name listening on a TCP port on Windows."""
80
+ try:
81
+ res = subprocess.run(
82
+ ["netstat", "-ano", "-p", "tcp"],
83
+ capture_output=True, text=True, timeout=2
84
+ )
85
+ target = f":{port}"
86
+ for line in res.stdout.splitlines():
87
+ line = line.strip()
88
+ if "LISTENING" in line and target in line:
89
+ parts = line.split()
90
+ if len(parts) >= 5:
91
+ pid_str = parts[-1]
92
+ if pid_str.isdigit():
93
+ pid = int(pid_str)
94
+ name = f"PID {pid}"
95
+ try:
96
+ t_res = subprocess.run(
97
+ ["tasklist.exe", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
98
+ capture_output=True, text=True, timeout=2
99
+ )
100
+ if t_res.stdout:
101
+ csv_parts = t_res.stdout.strip().split('","')
102
+ if csv_parts and csv_parts[0]:
103
+ name = csv_parts[0].replace('"', '')
104
+ except Exception:
105
+ pass
106
+ return pid, name
107
+ except Exception:
108
+ pass
109
+
110
+ # Fallback to PowerShell if netstat was unavailable
111
+ try:
112
+ res = subprocess.run(
113
+ ["powershell.exe", "-NoProfile", "-Command",
114
+ f"(Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess)"],
115
+ capture_output=True, text=True, timeout=3
116
+ )
117
+ pid_str = res.stdout.strip()
118
+ if pid_str.isdigit():
119
+ pid = int(pid_str)
120
+ proc_res = subprocess.run(
121
+ ["powershell.exe", "-NoProfile", "-Command",
122
+ f"(Get-Process -Id {pid} -ErrorAction SilentlyContinue).ProcessName"],
123
+ capture_output=True, text=True, timeout=3
124
+ )
125
+ name = proc_res.stdout.strip() or f"PID {pid}"
126
+ return pid, name
127
+ except Exception:
128
+ pass
129
+ return None, None
130
+
131
+
132
+ def nginx_start() -> None:
133
+ exe = nginx_exe()
134
+ if not exe.exists():
135
+ raise FileNotFoundError("Nginx isn't installed -- run `ndev setup` first")
136
+
137
+ if nginx_is_running():
138
+ return
139
+
140
+ # Check if port 80 is occupied by an external service (e.g. Apache / IIS / W3SVC)
141
+ import socket
142
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
143
+ s.settimeout(0.5)
144
+ if s.connect_ex(("127.0.0.1", 80)) == 0:
145
+ pid, proc_name = _find_port_owner(80)
146
+ detail = f"by '{proc_name}' (PID {pid})" if proc_name else "by another application"
147
+ if proc_name and proc_name.lower() in ("httpd", "apache", "apache2"):
148
+ advice = "Please stop your Apache / XAMPP / WAMP web server service before starting Nginx."
149
+ elif proc_name and proc_name.lower() in ("system", "inetinfo", "w3wp", "iis"):
150
+ advice = "Port 80 is occupied by Windows IIS / W3SVC. Run `iisreset /stop` in Administrator PowerShell to stop IIS."
151
+ else:
152
+ advice = f"Please close or stop '{proc_name or 'the conflicting process'}' to free up port 80."
153
+
154
+ raise RuntimeError(f"Port 80 is already in use {detail}. {advice}")
155
+
156
+ # Ensure logs and temp directories exist
157
+ paths.NGINX_LOGS_DIR.mkdir(parents=True, exist_ok=True)
158
+ temp_dir = paths.NGINX_DIR / "temp"
159
+ temp_dir.mkdir(parents=True, exist_ok=True)
160
+
161
+ subprocess.Popen(
162
+ [str(exe)],
163
+ cwd=str(paths.NGINX_DIR),
164
+ creationflags=CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
165
+ )
166
+ _wait_for_port(80, timeout=2.0)
167
+
168
+
169
+ def nginx_stop() -> None:
170
+ exe = nginx_exe()
171
+ if not exe.exists():
172
+ return
173
+ try:
174
+ subprocess.run([str(exe), "-s", "stop"], cwd=str(paths.NGINX_DIR), capture_output=True, timeout=5)
175
+ except Exception:
176
+ pass
177
+
178
+ # Terminate the specific Nginx master PID tree if recorded in nginx.pid
179
+ pid_file = paths.NGINX_LOGS_DIR / "nginx.pid"
180
+ if pid_file.exists():
181
+ try:
182
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
183
+ subprocess.run(["taskkill.exe", "/F", "/PID", str(pid), "/T"], capture_output=True)
184
+ pid_file.unlink(missing_ok=True)
185
+ except Exception:
186
+ pass
187
+
188
+ # Fallback to ensure no orphaned ndev nginx instances remain if still running
189
+ if nginx_is_running():
190
+ try:
191
+ subprocess.run(["taskkill.exe", "/F", "/IM", "nginx.exe", "/T"], capture_output=True)
192
+ except Exception:
193
+ pass
194
+
195
+ _wait_for_port_closed(80, timeout=1.5)
196
+
197
+
198
+ def nginx_reload() -> None:
199
+ exe = nginx_exe()
200
+ if not exe.exists():
201
+ raise FileNotFoundError("Nginx isn't installed -- run `ndev setup` first")
202
+
203
+ # Test config before reloading
204
+ check = nginx_test_config()
205
+ if check.returncode != 0:
206
+ err_msg = (check.stderr.strip() + "\n" + check.stdout.strip()).strip()
207
+ raise RuntimeError(f"Nginx configuration test (nginx -t) failed:\n{err_msg}")
208
+
209
+ if nginx_is_running():
210
+ subprocess.run([str(exe), "-s", "reload"], cwd=str(paths.NGINX_DIR), capture_output=True)
211
+ else:
212
+ nginx_start()
213
+
214
+
215
+ def nginx_test_config() -> subprocess.CompletedProcess:
216
+ exe = nginx_exe()
217
+ if not exe.exists():
218
+ raise FileNotFoundError("Nginx isn't installed -- run `ndev setup` first")
219
+ return subprocess.run(
220
+ [str(exe), "-t"],
221
+ cwd=str(paths.NGINX_DIR),
222
+ capture_output=True,
223
+ text=True
224
+ )
225
+
226
+
227
+ def nginx_status() -> dict | None:
228
+ if not nginx_is_installed():
229
+ return None
230
+ running = nginx_is_running()
231
+ pid = None
232
+ pid_file = paths.NGINX_LOGS_DIR / "nginx.pid"
233
+ if pid_file.exists() and running:
234
+ try:
235
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
236
+ except Exception:
237
+ pass
238
+ return {"installed": True, "running": running, "pid": pid, "port": 80}
239
+
240
+
241
+ # ---- MariaDB ----------------------------------------------------------------
242
+
243
+ _MARIADB_STATE = paths.RUN_DIR / "mariadb.json"
244
+
245
+
246
+ def mariadb_is_installed() -> bool:
247
+ return (paths.MARIADB_DIR / "bin" / "mysqld.exe").exists()
248
+
249
+
250
+ def mariadb_is_running() -> bool:
251
+ st = mariadb_status()
252
+ return bool(st and st.get("running"))
253
+
254
+
255
+ def mariadb_start(root_password: str = "root") -> int:
256
+ exe = paths.MARIADB_DIR / "bin" / "mysqld.exe"
257
+ if not exe.exists():
258
+ raise FileNotFoundError("MariaDB isn't installed -- run `ndev setup` first")
259
+
260
+ st = mariadb_status()
261
+ if st and st.get("running"):
262
+ return st["pid"]
263
+
264
+ # Check if port 3306 is occupied by an external MySQL / MariaDB service
265
+ import socket
266
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
267
+ s.settimeout(0.5)
268
+ if s.connect_ex(("127.0.0.1", 3306)) == 0:
269
+ pid, proc_name = _find_port_owner(3306)
270
+ detail = f"by '{proc_name}' (PID {pid})" if proc_name else "by another application"
271
+ raise RuntimeError(
272
+ f"Port 3306 is already in use {detail}. "
273
+ "Please stop any existing system MySQL / MariaDB service before starting ndev MariaDB."
274
+ )
275
+
276
+ data_dir = paths.MARIADB_DIR / "data"
277
+ if not data_dir.exists() or not any(data_dir.iterdir()):
278
+ from . import setup
279
+ setup._init_mariadb_data_dir()
280
+
281
+ data_dir.mkdir(parents=True, exist_ok=True)
282
+ clean_data_dir = str(data_dir.resolve()).replace("\\", "/")
283
+ my_ini = paths.MARIADB_DIR / "my.ini"
284
+
285
+ cmd = [str(exe)]
286
+ if my_ini.exists():
287
+ cmd.append(f"--defaults-file={my_ini}")
288
+ else:
289
+ cmd.extend([f"--datadir={clean_data_dir}", "--bind-address=127.0.0.1", "--port=3306"])
290
+ cmd.append("--console")
291
+
292
+ proc = subprocess.Popen(
293
+ cmd,
294
+ cwd=str(paths.MARIADB_DIR),
295
+ creationflags=CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW,
296
+ )
297
+ paths.ensure_dirs()
298
+ _MARIADB_STATE.write_text(json.dumps({"pid": proc.pid, "running": True, "port": 3306}), encoding="utf-8")
299
+ _wait_for_port(3306, timeout=5.0)
300
+ return proc.pid
301
+
302
+
303
+ def mariadb_stop(root_password: str = "root") -> None:
304
+ admin_exe = paths.MARIADB_DIR / "bin" / "mysqladmin.exe"
305
+ if admin_exe.exists():
306
+ try:
307
+ cmd = [str(admin_exe), "-u", "root"]
308
+ if root_password:
309
+ cmd.append(f"--password={root_password}")
310
+ cmd.append("shutdown")
311
+ subprocess.run(cmd, capture_output=True, timeout=5)
312
+ except Exception:
313
+ pass
314
+
315
+ st = mariadb_status()
316
+ if st and st.get("pid"):
317
+ pid = st["pid"]
318
+ terminated = False
319
+ try:
320
+ handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
321
+ if handle:
322
+ ctypes.windll.kernel32.TerminateProcess(handle, 0)
323
+ ctypes.windll.kernel32.CloseHandle(handle)
324
+ terminated = True
325
+ except Exception:
326
+ pass
327
+ if not terminated or fcgi.is_pid_alive(pid, expected_name="mysqld"):
328
+ try:
329
+ subprocess.run(["taskkill.exe", "/F", "/PID", str(pid), "/T"], capture_output=True)
330
+ except Exception:
331
+ pass
332
+
333
+ _MARIADB_STATE.unlink(missing_ok=True)
334
+ _wait_for_port_closed(3306, timeout=2.0)
335
+
336
+
337
+ def mariadb_status() -> dict | None:
338
+ if not _MARIADB_STATE.exists():
339
+ return None
340
+ try:
341
+ data = json.loads(_MARIADB_STATE.read_text(encoding="utf-8"))
342
+ pid = data.get("pid")
343
+ if pid and fcgi.is_pid_alive(pid):
344
+ data["running"] = True
345
+ return data
346
+ except Exception:
347
+ pass
348
+ _MARIADB_STATE.unlink(missing_ok=True)
349
+ return None