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.
- ndev/__init__.py +8 -0
- ndev/__main__.py +4 -0
- ndev/cli.py +24 -0
- ndev/common/__init__.py +3 -0
- ndev/common/config.py +114 -0
- ndev/common/constants.py +51 -0
- ndev/common/github.py +13 -0
- ndev/common/logger.py +11 -0
- ndev/common/manifest.py +41 -0
- ndev/common/utils.py +96 -0
- ndev/linux/__init__.py +1 -0
- ndev/linux/chroot/manager.py +63 -0
- ndev/linux/chroot/packages.py +91 -0
- ndev/linux/chroot/shell.py +9 -0
- ndev/linux/cli.py +235 -0
- ndev/linux/commands/available.py +38 -0
- ndev/linux/commands/clean.py +25 -0
- ndev/linux/commands/ctl.py +192 -0
- ndev/linux/commands/current.py +11 -0
- ndev/linux/commands/db.py +319 -0
- ndev/linux/commands/doctor.py +56 -0
- ndev/linux/commands/grok.py +75 -0
- ndev/linux/commands/install.py +39 -0
- ndev/linux/commands/list.py +47 -0
- ndev/linux/commands/logs.py +36 -0
- ndev/linux/commands/mailpit.py +82 -0
- ndev/linux/commands/reload.py +26 -0
- ndev/linux/commands/restart.py +34 -0
- ndev/linux/commands/setup.py +113 -0
- ndev/linux/commands/start.py +34 -0
- ndev/linux/commands/status.py +81 -0
- ndev/linux/commands/stop.py +34 -0
- ndev/linux/commands/uninstall.py +69 -0
- ndev/linux/commands/update.py +57 -0
- ndev/linux/commands/upgrade.py +81 -0
- ndev/linux/commands/use.py +108 -0
- ndev/linux/commands/vhost.py +350 -0
- ndev/linux/php/builder.py +183 -0
- ndev/linux/php/downloader.py +58 -0
- ndev/linux/php/extensions.py +146 -0
- ndev/linux/php/installer.py +42 -0
- ndev/linux/php/resolver.py +59 -0
- ndev/linux/php/templates.py +128 -0
- ndev/linux/runtime/fpm.py +117 -0
- ndev/linux/runtime/mailpit.py +244 -0
- ndev/linux/runtime/pma.py +223 -0
- ndev/linux/runtime/process.py +37 -0
- ndev/linux/runtime/sockets.py +16 -0
- ndev/linux/runtime/upgrade.py +431 -0
- ndev/linux/tui.py +1423 -0
- ndev/main.py +52 -0
- ndev/tui.py +23 -0
- ndev/win/__init__.py +1 -0
- ndev/win/cli.py +1898 -0
- ndev/win/commands/__init__.py +0 -0
- ndev/win/core/__init__.py +0 -0
- ndev/win/core/db.py +265 -0
- ndev/win/core/elevate.py +94 -0
- ndev/win/core/ext.py +241 -0
- ndev/win/core/fcgi.py +216 -0
- ndev/win/core/grok.py +55 -0
- ndev/win/core/logs.py +66 -0
- ndev/win/core/mailpit.py +236 -0
- ndev/win/core/mkcert.py +65 -0
- ndev/win/core/paths.py +85 -0
- ndev/win/core/php.py +533 -0
- ndev/win/core/pma.py +190 -0
- ndev/win/core/services.py +349 -0
- ndev/win/core/setup.py +361 -0
- ndev/win/core/upgrade.py +513 -0
- ndev/win/core/vhost.py +289 -0
- ndev/win/templates/vhost.conf.tmpl +33 -0
- ndev/win/templates/vhost_ssl.conf.tmpl +43 -0
- ndev/win/tui.py +1313 -0
- ndev_stack-0.1.0.dist-info/METADATA +553 -0
- ndev_stack-0.1.0.dist-info/RECORD +79 -0
- ndev_stack-0.1.0.dist-info/WHEEL +5 -0
- ndev_stack-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|