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/fcgi.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastCGI worker pool management for Windows.
|
|
3
|
+
|
|
4
|
+
Windows has no php-fpm SAPI (it's POSIX-only: relies on fork()).
|
|
5
|
+
The native substitute is a pool of `php-cgi.exe` processes, each
|
|
6
|
+
bound to its own TCP port, load-balanced by an Nginx `upstream` block.
|
|
7
|
+
|
|
8
|
+
State (pid + ports per version) is persisted as JSON under
|
|
9
|
+
~/.ndev/run/<version>.json so `stop`/`status` work across CLI invocations.
|
|
10
|
+
Process liveness is verified on every query.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ctypes
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
from . import paths
|
|
23
|
+
|
|
24
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
25
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
26
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
27
|
+
PROCESS_TERMINATE = 0x0001
|
|
28
|
+
STILL_ACTIVE = 259
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class WorkerState:
|
|
33
|
+
pid: int
|
|
34
|
+
port: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_pid_alive(pid: int, expected_name: str | None = None) -> bool:
|
|
38
|
+
"""Check if a process with given PID is actively running on Windows, matching exe name if specified."""
|
|
39
|
+
if pid <= 0:
|
|
40
|
+
return False
|
|
41
|
+
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
42
|
+
if not handle:
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
exit_code = ctypes.c_ulong(0)
|
|
46
|
+
success = ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
|
|
47
|
+
if not success or exit_code.value != STILL_ACTIVE:
|
|
48
|
+
return False
|
|
49
|
+
if expected_name:
|
|
50
|
+
buf = (ctypes.c_wchar * 1024)()
|
|
51
|
+
size = ctypes.c_ulong(1024)
|
|
52
|
+
if ctypes.windll.kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
|
|
53
|
+
return expected_name.lower() in buf.value.lower()
|
|
54
|
+
return True
|
|
55
|
+
finally:
|
|
56
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _state_file(version: str) -> Path:
|
|
60
|
+
# Normalize version string for filenames (e.g. "8.4" or "8.4.25")
|
|
61
|
+
return paths.RUN_DIR / f"php_{version}.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_state(version: str) -> list[WorkerState]:
|
|
65
|
+
f = _state_file(version)
|
|
66
|
+
if not f.exists():
|
|
67
|
+
return []
|
|
68
|
+
try:
|
|
69
|
+
raw = json.loads(f.read_text(encoding="utf-8"))
|
|
70
|
+
workers = [WorkerState(**w) for w in raw]
|
|
71
|
+
except Exception:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
# Filter out dead workers and recycled PIDs
|
|
75
|
+
alive_workers = [w for w in workers if is_pid_alive(w.pid, expected_name="php-cgi")]
|
|
76
|
+
if len(alive_workers) != len(workers):
|
|
77
|
+
if alive_workers:
|
|
78
|
+
_save_state(version, alive_workers)
|
|
79
|
+
else:
|
|
80
|
+
f.unlink(missing_ok=True)
|
|
81
|
+
return alive_workers
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _save_state(version: str, workers: list[WorkerState]) -> None:
|
|
85
|
+
paths.ensure_dirs()
|
|
86
|
+
_state_file(version).write_text(json.dumps([asdict(w) for w in workers], indent=2), encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ports_for(version: str, count: int, base_port: int) -> list[int]:
|
|
90
|
+
"""
|
|
91
|
+
Deterministic, non-overlapping port block per version so multiple
|
|
92
|
+
PHP versions can run their pools simultaneously without port collisions.
|
|
93
|
+
Allocates up to 30 workers per minor version without overlap.
|
|
94
|
+
"""
|
|
95
|
+
clean_ver = version.split()[0]
|
|
96
|
+
parts = clean_ver.split(".")
|
|
97
|
+
try:
|
|
98
|
+
major = int(parts[0])
|
|
99
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
100
|
+
offset = (major * 500 + minor * 30)
|
|
101
|
+
except (ValueError, IndexError):
|
|
102
|
+
import zlib
|
|
103
|
+
offset = (zlib.crc32(clean_ver.encode("utf-8")) % 50) * 50
|
|
104
|
+
return [base_port + offset + i for i in range(count)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _wait_for_ports(ports: list[int], timeout: float = 3.0) -> bool:
|
|
108
|
+
"""Poll TCP ports until all are accepting connections or timeout occurs."""
|
|
109
|
+
import socket
|
|
110
|
+
import time
|
|
111
|
+
deadline = time.time() + timeout
|
|
112
|
+
while time.time() < deadline:
|
|
113
|
+
all_open = True
|
|
114
|
+
for port in ports:
|
|
115
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
116
|
+
s.settimeout(0.1)
|
|
117
|
+
if s.connect_ex(("127.0.0.1", port)) != 0:
|
|
118
|
+
all_open = False
|
|
119
|
+
break
|
|
120
|
+
if all_open:
|
|
121
|
+
return True
|
|
122
|
+
time.sleep(0.05)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def start(version: str, php_cgi_path: Path, workers: int, base_port: int) -> list[WorkerState]:
|
|
127
|
+
current_state = _load_state(version)
|
|
128
|
+
if current_state:
|
|
129
|
+
return current_state
|
|
130
|
+
|
|
131
|
+
if not Path(php_cgi_path).exists():
|
|
132
|
+
raise FileNotFoundError(f"php-cgi.exe not found at {php_cgi_path}")
|
|
133
|
+
|
|
134
|
+
ports = ports_for(version, workers, base_port)
|
|
135
|
+
state: list[WorkerState] = []
|
|
136
|
+
|
|
137
|
+
env = dict(os.environ)
|
|
138
|
+
env["PHP_FCGI_CHILDREN"] = "0"
|
|
139
|
+
env["PHP_FCGI_MAX_REQUESTS"] = "0"
|
|
140
|
+
|
|
141
|
+
for port in ports:
|
|
142
|
+
proc = subprocess.Popen(
|
|
143
|
+
[str(php_cgi_path), "-b", f"127.0.0.1:{port}"],
|
|
144
|
+
env=env,
|
|
145
|
+
cwd=str(php_cgi_path.parent),
|
|
146
|
+
creationflags=CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW,
|
|
147
|
+
)
|
|
148
|
+
state.append(WorkerState(pid=proc.pid, port=port))
|
|
149
|
+
|
|
150
|
+
_wait_for_ports(ports)
|
|
151
|
+
_save_state(version, state)
|
|
152
|
+
return state
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def stop(version: str) -> None:
|
|
156
|
+
f = _state_file(version)
|
|
157
|
+
if not f.exists():
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
raw = json.loads(f.read_text(encoding="utf-8"))
|
|
161
|
+
workers = [WorkerState(**w) for w in raw]
|
|
162
|
+
except Exception:
|
|
163
|
+
workers = []
|
|
164
|
+
|
|
165
|
+
for w in workers:
|
|
166
|
+
if not is_pid_alive(w.pid, expected_name="php-cgi"):
|
|
167
|
+
continue
|
|
168
|
+
terminated = False
|
|
169
|
+
try:
|
|
170
|
+
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, w.pid)
|
|
171
|
+
if handle:
|
|
172
|
+
ctypes.windll.kernel32.TerminateProcess(handle, 0)
|
|
173
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
174
|
+
terminated = True
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
if not terminated or is_pid_alive(w.pid, expected_name="php-cgi"):
|
|
178
|
+
try:
|
|
179
|
+
subprocess.run(["taskkill.exe", "/F", "/PID", str(w.pid)], capture_output=True)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
f.unlink(missing_ok=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def restart(version: str, workers: int | None = None, base_port: int | None = None) -> list[WorkerState]:
|
|
187
|
+
# Local import to avoid circular imports
|
|
188
|
+
from . import php
|
|
189
|
+
cfg = paths.load_config()
|
|
190
|
+
n_workers = workers or cfg["fcgi_workers_per_version"]
|
|
191
|
+
port_base = base_port or cfg["fcgi_base_port"]
|
|
192
|
+
|
|
193
|
+
stop(version)
|
|
194
|
+
return start(version, php.php_cgi_exe(version), n_workers, port_base)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def status(version: str) -> list[WorkerState]:
|
|
198
|
+
return _load_state(version)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def nginx_upstream_name(version: str, domain: str | None = None) -> str:
|
|
202
|
+
clean_ver = version.replace(".", "_")
|
|
203
|
+
if domain:
|
|
204
|
+
import re
|
|
205
|
+
clean_domain = re.sub(r"[^a-zA-Z0-9_]", "_", domain.strip().lower())
|
|
206
|
+
return f"php_{clean_ver}_{clean_domain}"
|
|
207
|
+
return f"php_{clean_ver}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_upstream_block(version: str, domain: str | None = None) -> str:
|
|
211
|
+
workers = status(version)
|
|
212
|
+
if not workers:
|
|
213
|
+
raise RuntimeError(f"PHP {version} pool is not running; start it first")
|
|
214
|
+
servers = "\n".join(f" server 127.0.0.1:{w.port};" for w in workers)
|
|
215
|
+
u_name = nginx_upstream_name(version, domain=domain)
|
|
216
|
+
return f"upstream {u_name} {{\n{servers}\n}}\n"
|
ndev/win/core/grok.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ngrok tunnel wrapper for ndev-win virtual hosts.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from . import paths, vhost
|
|
12
|
+
|
|
13
|
+
HTTP_PORT = 80
|
|
14
|
+
HTTPS_PORT = 443
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_vhosts() -> list[str]:
|
|
18
|
+
"""Return list of domain names with configured Nginx virtual hosts."""
|
|
19
|
+
return [v["domain"] for v in vhost.list_vhosts()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ngrok_exe() -> str:
|
|
23
|
+
# 1. Check shim dir
|
|
24
|
+
shim_exe = paths.SHIM_DIR / "ngrok.exe"
|
|
25
|
+
if shim_exe.exists():
|
|
26
|
+
return str(shim_exe)
|
|
27
|
+
|
|
28
|
+
# 2. Check config
|
|
29
|
+
cfg = paths.load_config()
|
|
30
|
+
path = cfg.get("ngrok_path")
|
|
31
|
+
if path and Path(path).exists():
|
|
32
|
+
return str(path)
|
|
33
|
+
|
|
34
|
+
# 3. Check system PATH
|
|
35
|
+
which_path = shutil.which("ngrok") or shutil.which("ngrok.exe")
|
|
36
|
+
if which_path:
|
|
37
|
+
return which_path
|
|
38
|
+
|
|
39
|
+
raise FileNotFoundError("ngrok isn't installed -- run `ndev setup` first")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def start_tunnel(domain: str, ssl: bool = False) -> subprocess.Popen:
|
|
43
|
+
"""
|
|
44
|
+
Start `ngrok http` against Nginx, rewriting Host header to `domain`.
|
|
45
|
+
"""
|
|
46
|
+
import re
|
|
47
|
+
clean_domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
|
|
48
|
+
known = list_vhosts()
|
|
49
|
+
if clean_domain not in known:
|
|
50
|
+
raise FileNotFoundError(
|
|
51
|
+
f"No vhost found for '{clean_domain}'. Known vhosts: {known or '(none)'}"
|
|
52
|
+
)
|
|
53
|
+
target = f"https://localhost:{HTTPS_PORT}" if ssl else str(HTTP_PORT)
|
|
54
|
+
cmd = [_ngrok_exe(), "http", target, f"--host-header={clean_domain}"]
|
|
55
|
+
return subprocess.Popen(cmd)
|
ndev/win/core/logs.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Log viewing utilities for ndev-win.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from . import paths, php
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_available_logs() -> dict[str, Path]:
|
|
13
|
+
"""Return dictionary of log name -> log file Path."""
|
|
14
|
+
logs: dict[str, Path] = {}
|
|
15
|
+
|
|
16
|
+
# Nginx logs
|
|
17
|
+
if paths.NGINX_LOGS_DIR.exists():
|
|
18
|
+
for p in paths.NGINX_LOGS_DIR.glob("*.log"):
|
|
19
|
+
logs[f"nginx:{p.stem}"] = p
|
|
20
|
+
|
|
21
|
+
# MariaDB logs
|
|
22
|
+
if paths.MARIADB_DIR.exists():
|
|
23
|
+
data_dir = paths.MARIADB_DIR / "data"
|
|
24
|
+
if data_dir.exists():
|
|
25
|
+
for p in data_dir.glob("*.err"):
|
|
26
|
+
logs[f"mariadb:{p.stem}"] = p
|
|
27
|
+
|
|
28
|
+
# PHP logs (look in ~/.ndev/php/<version>/ or ~/.ndev/run/ / temp)
|
|
29
|
+
for v in php.list_installed():
|
|
30
|
+
v_dir = paths.version_dir(v)
|
|
31
|
+
for log_candidate in [v_dir / "php_error.log", v_dir / "error.log"]:
|
|
32
|
+
if log_candidate.exists():
|
|
33
|
+
logs[f"php:{v}"] = log_candidate
|
|
34
|
+
|
|
35
|
+
return logs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_log_tail(log_path: Path, lines: int = 50) -> list[str]:
|
|
39
|
+
"""Efficiently read the last N lines of a file without loading large files into memory."""
|
|
40
|
+
if not log_path.exists():
|
|
41
|
+
return []
|
|
42
|
+
try:
|
|
43
|
+
file_size = log_path.stat().st_size
|
|
44
|
+
if file_size == 0:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
# Read backward in chunks from the end of the file
|
|
48
|
+
chunk_size = 8192
|
|
49
|
+
chunks: list[bytes] = []
|
|
50
|
+
newlines_found = 0
|
|
51
|
+
with log_path.open("rb") as f:
|
|
52
|
+
pos = file_size
|
|
53
|
+
while pos > 0 and newlines_found <= lines:
|
|
54
|
+
read_len = min(chunk_size, pos)
|
|
55
|
+
pos -= read_len
|
|
56
|
+
f.seek(pos)
|
|
57
|
+
chunk = f.read(read_len)
|
|
58
|
+
chunks.append(chunk)
|
|
59
|
+
newlines_found += chunk.count(b"\n")
|
|
60
|
+
|
|
61
|
+
raw = b"".join(reversed(chunks))
|
|
62
|
+
text = raw.decode("utf-8", errors="ignore")
|
|
63
|
+
all_lines = text.splitlines()
|
|
64
|
+
return all_lines[-lines:]
|
|
65
|
+
except Exception:
|
|
66
|
+
return []
|
ndev/win/core/mailpit.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mailpit - local email sandbox & SMTP catcher (https://github.com/axllent/mailpit).
|
|
3
|
+
|
|
4
|
+
Downloads the prebuilt Windows binary from GitHub releases and manages the
|
|
5
|
+
background process on Windows.
|
|
6
|
+
|
|
7
|
+
SMTP server: 127.0.0.1:1025 (default)
|
|
8
|
+
Web UI: http://127.0.0.1:8025 (default)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
import zipfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from . import fcgi, paths
|
|
23
|
+
|
|
24
|
+
# ── constants ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
GITHUB_REPO = "axllent/mailpit"
|
|
27
|
+
RELEASES_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
|
28
|
+
RELEASES_PAGE_URL = f"https://github.com/{GITHUB_REPO}/releases"
|
|
29
|
+
|
|
30
|
+
DEFAULT_SMTP_PORT = 1025
|
|
31
|
+
DEFAULT_WEB_PORT = 8025
|
|
32
|
+
|
|
33
|
+
BINARY_NAME = "mailpit.exe"
|
|
34
|
+
BINARY_PATH = paths.SHIM_DIR / BINARY_NAME
|
|
35
|
+
|
|
36
|
+
_STATE_FILE = paths.RUN_DIR / "mailpit.json"
|
|
37
|
+
|
|
38
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
39
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def is_installed() -> bool:
|
|
45
|
+
return BINARY_PATH.exists()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def binary() -> Path:
|
|
49
|
+
if BINARY_PATH.exists():
|
|
50
|
+
return BINARY_PATH
|
|
51
|
+
raise FileNotFoundError(
|
|
52
|
+
"Mailpit binary not found. Run `ndev mailpit install` first."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fetch_latest_release() -> dict:
|
|
57
|
+
req = urllib.request.Request(
|
|
58
|
+
RELEASES_API_URL,
|
|
59
|
+
headers={"User-Agent": "ndev/0.1.0"},
|
|
60
|
+
)
|
|
61
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
62
|
+
return json.loads(resp.read())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_windows_asset(release: dict) -> Optional[dict]:
|
|
66
|
+
"""Return the windows-amd64 asset, or None."""
|
|
67
|
+
for asset in release.get("assets", []):
|
|
68
|
+
name = asset["name"].lower()
|
|
69
|
+
if "windows" in name and "amd64" in name:
|
|
70
|
+
return asset
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── core operations ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def install() -> Path:
|
|
77
|
+
"""
|
|
78
|
+
Download the prebuilt Mailpit Windows binary from GitHub releases
|
|
79
|
+
and save it to ~/.ndev/shims/mailpit.exe.
|
|
80
|
+
"""
|
|
81
|
+
paths.ensure_dirs()
|
|
82
|
+
|
|
83
|
+
release = _fetch_latest_release()
|
|
84
|
+
version = release.get("tag_name", "unknown")
|
|
85
|
+
asset = _find_windows_asset(release)
|
|
86
|
+
|
|
87
|
+
if asset is None:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"No prebuilt Windows binary found in Mailpit {version}.\n"
|
|
90
|
+
f"Check {RELEASES_PAGE_URL} for available assets."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
url = asset["browser_download_url"]
|
|
94
|
+
name = asset["name"]
|
|
95
|
+
dl_path = paths.DOWNLOADS_DIR / name
|
|
96
|
+
|
|
97
|
+
# Download (skip if already cached)
|
|
98
|
+
if not dl_path.exists():
|
|
99
|
+
req = urllib.request.Request(url, headers={"User-Agent": "ndev/0.1.0"})
|
|
100
|
+
tmp = dl_path.with_suffix(".part")
|
|
101
|
+
with urllib.request.urlopen(req, timeout=180) as resp, open(tmp, "wb") as f:
|
|
102
|
+
shutil.copyfileobj(resp, f)
|
|
103
|
+
tmp.rename(dl_path)
|
|
104
|
+
|
|
105
|
+
# Extract .exe from zip
|
|
106
|
+
with zipfile.ZipFile(dl_path) as zf:
|
|
107
|
+
for member in zf.namelist():
|
|
108
|
+
if member.lower().endswith(".exe") and "mailpit" in member.lower():
|
|
109
|
+
with zf.open(member) as src, open(BINARY_PATH, "wb") as dst:
|
|
110
|
+
shutil.copyfileobj(src, dst)
|
|
111
|
+
break
|
|
112
|
+
else:
|
|
113
|
+
raise RuntimeError(f"Could not find mailpit.exe inside {name}")
|
|
114
|
+
|
|
115
|
+
return BINARY_PATH
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def start(
|
|
119
|
+
smtp_port: int = DEFAULT_SMTP_PORT,
|
|
120
|
+
web_port: int = DEFAULT_WEB_PORT,
|
|
121
|
+
) -> int:
|
|
122
|
+
"""Start Mailpit in the background. Returns PID."""
|
|
123
|
+
exe = binary()
|
|
124
|
+
|
|
125
|
+
st = status()
|
|
126
|
+
if st:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"Mailpit is already running at http://127.0.0.1:{st['web_port']} "
|
|
129
|
+
f"(PID {st['pid']})"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
for port, label in [(smtp_port, "SMTP"), (web_port, "Web UI")]:
|
|
133
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
134
|
+
s.settimeout(0.5)
|
|
135
|
+
if s.connect_ex(("127.0.0.1", port)) == 0:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"Port {port} ({label}) is already in use. "
|
|
138
|
+
"Choose a different port or stop the conflicting service."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
db_path = paths.NDEV_HOME / "mailpit.db"
|
|
142
|
+
cmd = [
|
|
143
|
+
str(exe),
|
|
144
|
+
"--smtp", f"127.0.0.1:{smtp_port}",
|
|
145
|
+
"--listen", f"127.0.0.1:{web_port}",
|
|
146
|
+
"--database", str(db_path),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
proc = subprocess.Popen(
|
|
150
|
+
cmd,
|
|
151
|
+
creationflags=CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
time.sleep(1.0)
|
|
155
|
+
if proc.poll() is not None:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
"Mailpit exited immediately after launch. "
|
|
158
|
+
"Check for port conflicts or run `mailpit --help` manually."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
paths.ensure_dirs()
|
|
162
|
+
state = {
|
|
163
|
+
"pid": proc.pid,
|
|
164
|
+
"smtp_port": smtp_port,
|
|
165
|
+
"web_port": web_port,
|
|
166
|
+
"url": f"http://127.0.0.1:{web_port}",
|
|
167
|
+
"smtp": f"127.0.0.1:{smtp_port}",
|
|
168
|
+
"database": str(db_path),
|
|
169
|
+
}
|
|
170
|
+
_STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
171
|
+
return proc.pid
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stop() -> None:
|
|
175
|
+
"""Stop the running Mailpit process."""
|
|
176
|
+
import ctypes
|
|
177
|
+
if not _STATE_FILE.exists():
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
|
|
181
|
+
pid = state.get("pid")
|
|
182
|
+
if pid and fcgi.is_pid_alive(pid):
|
|
183
|
+
terminated = False
|
|
184
|
+
try:
|
|
185
|
+
handle = ctypes.windll.kernel32.OpenProcess(0x0001, False, pid)
|
|
186
|
+
if handle:
|
|
187
|
+
ctypes.windll.kernel32.TerminateProcess(handle, 0)
|
|
188
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
189
|
+
terminated = True
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
if not terminated or fcgi.is_pid_alive(pid):
|
|
193
|
+
subprocess.run(
|
|
194
|
+
["taskkill.exe", "/F", "/PID", str(pid), "/T"],
|
|
195
|
+
capture_output=True,
|
|
196
|
+
)
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
_STATE_FILE.unlink(missing_ok=True)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def status() -> dict | None:
|
|
203
|
+
"""Return the current state dict if Mailpit is running, else None."""
|
|
204
|
+
if not _STATE_FILE.exists():
|
|
205
|
+
return None
|
|
206
|
+
try:
|
|
207
|
+
state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
|
|
208
|
+
pid = state.get("pid")
|
|
209
|
+
if pid and fcgi.is_pid_alive(pid):
|
|
210
|
+
return state
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
_STATE_FILE.unlink(missing_ok=True)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def restart(
|
|
218
|
+
smtp_port: int = DEFAULT_SMTP_PORT,
|
|
219
|
+
web_port: int = DEFAULT_WEB_PORT,
|
|
220
|
+
) -> int:
|
|
221
|
+
"""Stop (if running) then start Mailpit. Returns new PID."""
|
|
222
|
+
stop()
|
|
223
|
+
# Wait up to 3 s for the ports to be released
|
|
224
|
+
for _ in range(6):
|
|
225
|
+
ports_busy = False
|
|
226
|
+
for p in (smtp_port, web_port):
|
|
227
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
228
|
+
s.settimeout(0.3)
|
|
229
|
+
if s.connect_ex(("127.0.0.1", p)) == 0:
|
|
230
|
+
ports_busy = True
|
|
231
|
+
break
|
|
232
|
+
if not ports_busy:
|
|
233
|
+
break
|
|
234
|
+
time.sleep(0.5)
|
|
235
|
+
return start(smtp_port=smtp_port, web_port=web_port)
|
|
236
|
+
|
ndev/win/core/mkcert.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mkcert wrapper for local SSL certificates on Windows.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import paths
|
|
11
|
+
|
|
12
|
+
_CA_INSTALLED_MARKER = paths.NDEV_HOME / ".mkcert-ca-installed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _mkcert_path() -> str:
|
|
16
|
+
# 1. Check shim dir
|
|
17
|
+
shim_exe = paths.SHIM_DIR / "mkcert.exe"
|
|
18
|
+
if shim_exe.exists():
|
|
19
|
+
return str(shim_exe)
|
|
20
|
+
|
|
21
|
+
# 2. Check config
|
|
22
|
+
cfg = paths.load_config()
|
|
23
|
+
path = cfg.get("mkcert_path")
|
|
24
|
+
if path and Path(path).exists():
|
|
25
|
+
return str(path)
|
|
26
|
+
|
|
27
|
+
# 3. Check system PATH
|
|
28
|
+
which_path = shutil.which("mkcert") or shutil.which("mkcert.exe")
|
|
29
|
+
if which_path:
|
|
30
|
+
return which_path
|
|
31
|
+
|
|
32
|
+
raise FileNotFoundError("mkcert isn't installed -- run `ndev setup` first")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_ca_installed() -> bool:
|
|
36
|
+
return _CA_INSTALLED_MARKER.exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_ca_installed() -> None:
|
|
40
|
+
if _CA_INSTALLED_MARKER.exists():
|
|
41
|
+
return
|
|
42
|
+
subprocess.run([_mkcert_path(), "-install"], check=True)
|
|
43
|
+
paths.ensure_dirs()
|
|
44
|
+
_CA_INSTALLED_MARKER.write_text("ok", encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_cert(domain: str) -> tuple[str, str]:
|
|
48
|
+
"""
|
|
49
|
+
Generates <domain>.crt and <domain>.key under ~/.ndev/certs/<domain>/.
|
|
50
|
+
Returns (cert_path, key_path) as strings with forward slashes for Nginx.
|
|
51
|
+
"""
|
|
52
|
+
import re
|
|
53
|
+
clean_domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
|
|
54
|
+
ensure_ca_installed()
|
|
55
|
+
cert_dir = paths.CERTS_DIR / clean_domain
|
|
56
|
+
cert_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
cert_path = cert_dir / f"{clean_domain}.crt"
|
|
58
|
+
key_path = cert_dir / f"{clean_domain}.key"
|
|
59
|
+
|
|
60
|
+
if not (cert_path.exists() and key_path.exists()):
|
|
61
|
+
subprocess.run(
|
|
62
|
+
[_mkcert_path(), "-cert-file", str(cert_path), "-key-file", str(key_path), clean_domain, f"*.{clean_domain}"],
|
|
63
|
+
check=True,
|
|
64
|
+
)
|
|
65
|
+
return str(cert_path).replace("\\", "/"), str(key_path).replace("\\", "/")
|