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/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 []
@@ -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
+
@@ -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("\\", "/")