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/paths.py ADDED
@@ -0,0 +1,85 @@
1
+ """
2
+ Central path/config definitions for ndev-win.
3
+
4
+ Mirrors the Linux ndev layout (~/.ndev/...) but rooted under the
5
+ user's profile directory on Windows.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+
13
+ NDEV_HOME = Path(os.environ.get("NDEV_HOME", Path.home() / ".ndev"))
14
+
15
+ PHP_DIR = NDEV_HOME / "php" # ~/.ndev/php/<version>/
16
+ DOWNLOADS_DIR = NDEV_HOME / "downloads" # cached zip downloads
17
+ RUN_DIR = NDEV_HOME / "run" # pid files / worker state
18
+ CERTS_DIR = NDEV_HOME / "certs" # mkcert-generated certs
19
+ CACERT_PATH = CERTS_DIR / "cacert.pem" # Mozilla root CA bundle for PHP cURL/OpenSSL
20
+ NGINX_DIR = NDEV_HOME / "nginx" # native nginx install
21
+ NGINX_CONF_D = NGINX_DIR / "conf" / "ndev-vhosts"
22
+ NGINX_LOGS_DIR = NGINX_DIR / "logs"
23
+ MARIADB_DIR = NDEV_HOME / "mariadb"
24
+ PMA_DIR = NDEV_HOME / "pma"
25
+ TEMPLATES_DIR = NDEV_HOME / "templates"
26
+ CONFIG_FILE = NDEV_HOME / "config.json"
27
+ CURRENT_FILE = NDEV_HOME / "current" # active PHP version, plain text
28
+ SHIM_DIR = NDEV_HOME / "shims" # php.exe / php-cgi.exe shims for PATH
29
+ TEMP_DIR = NDEV_HOME / "temp" # temporary files and fastcgi temp buffers
30
+ SESSIONS_DIR = TEMP_DIR / "sessions" # PHP isolated session storage
31
+ BACKUPS_DIR = NDEV_HOME / "backups" # automatic snapshots before upgrades
32
+
33
+ HOSTS_PATH = Path(r"C:\Windows\System32\drivers\etc\hosts")
34
+
35
+ DEFAULT_CONFIG = {
36
+ "fcgi_base_port": 9000,
37
+ "fcgi_workers_per_version": 4,
38
+ "ngrok_path": None,
39
+ "mkcert_path": None,
40
+ }
41
+
42
+
43
+ def ensure_dirs() -> None:
44
+ for d in (PHP_DIR, DOWNLOADS_DIR, RUN_DIR, CERTS_DIR, NGINX_CONF_D,
45
+ NGINX_LOGS_DIR, MARIADB_DIR, PMA_DIR, TEMPLATES_DIR, SHIM_DIR,
46
+ TEMP_DIR, SESSIONS_DIR, BACKUPS_DIR):
47
+ d.mkdir(parents=True, exist_ok=True)
48
+
49
+
50
+ def load_config() -> dict:
51
+ ensure_dirs()
52
+ if not CONFIG_FILE.exists():
53
+ CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2), encoding="utf-8")
54
+ return dict(DEFAULT_CONFIG)
55
+ try:
56
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
57
+ except Exception:
58
+ data = {}
59
+ merged = dict(DEFAULT_CONFIG)
60
+ merged.update(data)
61
+ return merged
62
+
63
+
64
+ def save_config(cfg: dict) -> None:
65
+ ensure_dirs()
66
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
67
+
68
+
69
+ def version_dir(version: str) -> Path:
70
+ return PHP_DIR / version
71
+
72
+
73
+ def get_current_version() -> str | None:
74
+ if CURRENT_FILE.exists():
75
+ try:
76
+ v = CURRENT_FILE.read_text(encoding="utf-8").strip()
77
+ return v or None
78
+ except Exception:
79
+ return None
80
+ return None
81
+
82
+
83
+ def set_current_version(version: str) -> None:
84
+ ensure_dirs()
85
+ CURRENT_FILE.write_text(version, encoding="utf-8")
ndev/win/core/php.py ADDED
@@ -0,0 +1,533 @@
1
+ """
2
+ PHP version management for Windows using precompiled Windows binaries
3
+ from windows.php.net.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ import shutil
10
+ import urllib.request
11
+ import zipfile
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from . import paths
17
+
18
+ RELEASES_INDEX_URL = "https://windows.php.net/downloads/releases/releases.json"
19
+ ARCHIVES_INDEX_URL = "https://windows.php.net/downloads/releases/archives/"
20
+
21
+
22
+ @dataclass
23
+ class PhpRelease:
24
+ version: str # e.g. "8.4.25"
25
+ major_minor: str # e.g. "8.4"
26
+ thread_safe: bool # True for TS, False for NTS
27
+ arch: str # "x64" or "x86"
28
+ toolset: str # e.g. "vs17", "vs16", "vc15"
29
+ zip_url: str
30
+ is_archive: bool = False
31
+
32
+
33
+ def _fetch_json(url: str) -> dict:
34
+ req = urllib.request.Request(url, headers={"User-Agent": "ndev/0.1.0"})
35
+ with urllib.request.urlopen(req, timeout=30) as resp:
36
+ return json.loads(resp.read().decode("utf-8"))
37
+
38
+
39
+ def _fetch_html(url: str) -> str:
40
+ req = urllib.request.Request(url, headers={"User-Agent": "ndev/0.1.0"})
41
+ with urllib.request.urlopen(req, timeout=30) as resp:
42
+ return resp.read().decode("utf-8", errors="ignore")
43
+
44
+
45
+ def list_available(include_archives: bool = False) -> list[PhpRelease]:
46
+ """
47
+ Parse current active releases from windows.php.net's releases.json.
48
+ Optionally includes archived releases.
49
+ """
50
+ try:
51
+ data = _fetch_json(RELEASES_INDEX_URL)
52
+ except Exception:
53
+ data = {}
54
+
55
+ releases: list[PhpRelease] = []
56
+ for major_minor, info in data.items():
57
+ if not isinstance(info, dict):
58
+ continue
59
+ ver = info.get("version", major_minor)
60
+ for build_key, build_info in info.items():
61
+ if isinstance(build_info, dict) and "zip" in build_info:
62
+ zip_path = build_info["zip"].get("path", "")
63
+ if not zip_path:
64
+ continue
65
+ is_nts = "nts" in build_key.lower()
66
+ arch = "x86" if ("x86" in build_key.lower()) else "x64"
67
+ m_tool = re.search(r'(vs\d+|vc\d+)', build_key.lower())
68
+ toolset = m_tool.group(1) if m_tool else ""
69
+ zip_url = f"https://windows.php.net/downloads/releases/{zip_path}"
70
+ releases.append(PhpRelease(
71
+ version=ver,
72
+ major_minor=major_minor,
73
+ thread_safe=not is_nts,
74
+ arch=arch,
75
+ toolset=toolset,
76
+ zip_url=zip_url,
77
+ is_archive=False,
78
+ ))
79
+
80
+ if include_archives:
81
+ releases.extend(list_available_archives())
82
+
83
+ return releases
84
+
85
+
86
+ def list_available_archives() -> list[PhpRelease]:
87
+ """Parse archived PHP builds from windows.php.net/downloads/releases/archives/."""
88
+ try:
89
+ html = _fetch_html(ARCHIVES_INDEX_URL)
90
+ except Exception:
91
+ return []
92
+
93
+ pattern = re.compile(
94
+ r'href="php-(\d+\.\d+\.\d+)(?:-(nts))?-Win32-([A-Za-z0-9]+)-(x64|x86)\.zip"',
95
+ re.IGNORECASE
96
+ )
97
+ releases: list[PhpRelease] = []
98
+ seen = set()
99
+ for match in pattern.finditer(html):
100
+ ver, nts, toolset, arch = match.groups()
101
+ is_ts = (nts is None)
102
+ key = (ver, is_ts, arch.lower())
103
+ if key in seen:
104
+ continue
105
+ seen.add(key)
106
+ parts = ver.split(".")
107
+ mm = f"{parts[0]}.{parts[1]}"
108
+ filename = match.group(0).split('"')[1]
109
+ releases.append(PhpRelease(
110
+ version=ver,
111
+ major_minor=mm,
112
+ thread_safe=is_ts,
113
+ arch=arch.lower(),
114
+ toolset=toolset.lower(),
115
+ zip_url=f"https://windows.php.net/downloads/releases/archives/{filename}",
116
+ is_archive=True,
117
+ ))
118
+ return releases
119
+
120
+
121
+ def _version_key(v: str) -> tuple[int, ...]:
122
+ parts = []
123
+ for part in v.split("."):
124
+ try:
125
+ parts.append(int(part))
126
+ except ValueError:
127
+ parts.append(0)
128
+ return tuple(parts)
129
+
130
+
131
+ def resolve_release(version_query: str, arch: str = "x64", thread_safe: bool = True) -> PhpRelease | None:
132
+ """
133
+ Resolve a version query (e.g. '8.4', '8.4.25', '7.4') to a matching PhpRelease.
134
+ Checks active releases first, then falls back to archives.
135
+ """
136
+ q = version_query.strip().lower()
137
+ if q.startswith("php-"):
138
+ q = q[4:]
139
+ elif q.startswith("php"):
140
+ q = q[3:]
141
+
142
+ # Handle 'latest' keyword
143
+ if q == "latest":
144
+ current_rels = list_available(include_archives=False)
145
+ matches = [r for r in current_rels if r.arch == arch and r.thread_safe == thread_safe]
146
+ if matches:
147
+ matches.sort(key=lambda r: _version_key(r.version), reverse=True)
148
+ return matches[0]
149
+
150
+ # 1. Search in active releases
151
+ current_rels = list_available(include_archives=False)
152
+ matches = [
153
+ r for r in current_rels
154
+ if r.arch == arch and r.thread_safe == thread_safe and (
155
+ r.version == q or r.major_minor == q or r.version.startswith(q + ".") or r.major_minor.startswith(q + ".")
156
+ )
157
+ ]
158
+ if matches:
159
+ matches.sort(key=lambda r: _version_key(r.version), reverse=True)
160
+ return matches[0]
161
+
162
+ # 2. Search in archives
163
+ arch_rels = list_available_archives()
164
+ matches = [
165
+ r for r in arch_rels
166
+ if r.arch == arch and r.thread_safe == thread_safe and (
167
+ r.version == q or r.major_minor == q or r.version.startswith(q + ".") or r.major_minor.startswith(q + ".")
168
+ )
169
+ ]
170
+ if matches:
171
+ matches.sort(key=lambda r: _version_key(r.version), reverse=True)
172
+ return matches[0]
173
+
174
+ # 3. Looser search without TS/arch constraints if none found
175
+ all_rels = current_rels + arch_rels
176
+ any_matches = [
177
+ r for r in all_rels
178
+ if r.version == q or r.major_minor == q or r.version.startswith(q + ".") or r.major_minor.startswith(q + ".")
179
+ ]
180
+ if any_matches:
181
+ any_matches.sort(key=lambda r: _version_key(r.version), reverse=True)
182
+ return any_matches[0]
183
+
184
+ return None
185
+
186
+
187
+ def download_release(release: PhpRelease) -> Path:
188
+ paths.ensure_dirs()
189
+ dest = paths.DOWNLOADS_DIR / Path(release.zip_url).name
190
+ if dest.exists() and dest.stat().st_size > 0:
191
+ return dest
192
+ tmp = dest.with_suffix(".part")
193
+ req = urllib.request.Request(release.zip_url, headers={"User-Agent": "ndev/0.1.0"})
194
+ try:
195
+ with urllib.request.urlopen(req, timeout=180) as resp, open(tmp, "wb") as f:
196
+ chunk_size = 65536
197
+ while True:
198
+ chunk = resp.read(chunk_size)
199
+ if not chunk:
200
+ break
201
+ f.write(chunk)
202
+ if tmp.exists() and tmp.stat().st_size > 0:
203
+ if dest.exists():
204
+ dest.unlink()
205
+ tmp.rename(dest)
206
+ else:
207
+ raise RuntimeError(f"Download from {release.zip_url} resulted in empty file.")
208
+ except Exception:
209
+ if tmp.exists():
210
+ tmp.unlink(missing_ok=True)
211
+ raise
212
+ return dest
213
+
214
+
215
+ def install(version: str, zip_path: Path) -> Path:
216
+ """Extract a downloaded PHP zip into ~/.ndev/php/<version>/ and configure php.ini."""
217
+ target = paths.version_dir(version)
218
+ if target.exists():
219
+ if (target / "php.exe").exists():
220
+ raise FileExistsError(f"PHP {version} is already installed at {target}")
221
+ else:
222
+ shutil.rmtree(target, ignore_errors=True)
223
+ target.mkdir(parents=True, exist_ok=True)
224
+ with zipfile.ZipFile(zip_path) as zf:
225
+ zf.extractall(target)
226
+ _configure_php_ini(target)
227
+
228
+ # If this is the only installed version or no current version set, make it active
229
+ if get_current_version() is None or len(list_installed()) == 1:
230
+ use(version)
231
+
232
+ return target
233
+
234
+
235
+ def _configure_php_ini(version_dir: Path) -> None:
236
+ """
237
+ Initialize and optimize php.ini for development:
238
+ - Set extension_dir = "ext"
239
+ - Enable essential extensions (curl, mbstring, mysqli, pdo_mysql, openssl, fileinfo, gd, zip, sodium, exif, intl)
240
+ - Configure FastCGI parameters
241
+ - Increase memory & upload limits
242
+ """
243
+ src_dev = version_dir / "php.ini-development"
244
+ src_prod = version_dir / "php.ini-production"
245
+ dst = version_dir / "php.ini"
246
+
247
+ if not dst.exists():
248
+ if src_dev.exists():
249
+ shutil.copyfile(src_dev, dst)
250
+ elif src_prod.exists():
251
+ shutil.copyfile(src_prod, dst)
252
+
253
+ if not dst.exists():
254
+ return
255
+
256
+ text = dst.read_text(encoding="utf-8", errors="ignore")
257
+
258
+ # 1. Enable extension_dir = "ext"
259
+ text = re.sub(r'^[;\s]*extension_dir\s*=\s*"ext"', 'extension_dir = "ext"', text, flags=re.MULTILINE)
260
+ if 'extension_dir = "ext"' not in text:
261
+ text = 'extension_dir = "ext"\n' + text
262
+
263
+ # 2. Enable common extensions
264
+ common_extensions = [
265
+ "curl", "fileinfo", "gd", "intl", "mbstring", "exif",
266
+ "mysqli", "openssl", "pdo_mysql", "pdo_sqlite", "sqlite3",
267
+ "sodium", "zip"
268
+ ]
269
+ for ext in common_extensions:
270
+ # If commented out, uncomment it
271
+ text = re.sub(rf'^[;\s]*extension\s*=\s*(?:php_)?{ext}(?:\.dll)?', f'extension={ext}', text, flags=re.MULTILINE)
272
+
273
+ # 3. Configure FastCGI, OPcache, CA certs, and dev settings
274
+ replacements = [
275
+ (r'^[;\s]*cgi\.force_redirect\s*=.*', 'cgi.force_redirect = 0'),
276
+ (r'^[;\s]*cgi\.fix_pathinfo\s*=.*', 'cgi.fix_pathinfo = 1'),
277
+ (r'^[;\s]*memory_limit\s*=.*', 'memory_limit = 512M'),
278
+ (r'^[;\s]*upload_max_filesize\s*=.*', 'upload_max_filesize = 128M'),
279
+ (r'^[;\s]*post_max_size\s*=.*', 'post_max_size = 128M'),
280
+ (r'^[;\s]*max_execution_time\s*=.*', 'max_execution_time = 300'),
281
+ (r'^[;\s]*date\.timezone\s*=.*', 'date.timezone = UTC'),
282
+ (r'^[;\s]*opcache\.enable\s*=.*', 'opcache.enable = 1'),
283
+ (r'^[;\s]*opcache\.enable_cli\s*=.*', 'opcache.enable_cli = 1'),
284
+ (r'^[;\s]*opcache\.memory_consumption\s*=.*', 'opcache.memory_consumption = 128'),
285
+ (r'^[;\s]*realpath_cache_size\s*=.*', 'realpath_cache_size = 4096k'),
286
+ (r'^[;\s]*realpath_cache_ttl\s*=.*', 'realpath_cache_ttl = 600'),
287
+ (r'^[;\s]*error_reporting\s*=.*', 'error_reporting = E_ALL'),
288
+ (r'^[;\s]*display_errors\s*=.*', 'display_errors = On'),
289
+ (r'^[;\s]*display_startup_errors\s*=.*', 'display_startup_errors = On'),
290
+ (r'^[;\s]*default_charset\s*=.*', 'default_charset = "UTF-8"'),
291
+ (r'^[;\s]*max_input_vars\s*=.*', 'max_input_vars = 5000'),
292
+ ]
293
+ sessions_dir = paths.SESSIONS_DIR
294
+ sessions_dir.mkdir(parents=True, exist_ok=True)
295
+ clean_sessions = str(sessions_dir.resolve()).replace("\\", "/")
296
+ clean_temp = str(paths.TEMP_DIR.resolve()).replace("\\", "/")
297
+ clean_vdir = str(version_dir.resolve()).replace("\\", "/")
298
+
299
+ replacements.extend([
300
+ (r'^[;\s]*session\.save_path\s*=.*', f'session.save_path = "{clean_sessions}"'),
301
+ (r'^[;\s]*sys_temp_dir\s*=.*', f'sys_temp_dir = "{clean_temp}"'),
302
+ (r'^[;\s]*error_log\s*=.*', f'error_log = "{clean_vdir}/php_error.log"'),
303
+ ])
304
+
305
+ if paths.CACERT_PATH.exists():
306
+ clean_cacert = str(paths.CACERT_PATH.resolve()).replace("\\", "/")
307
+ replacements.extend([
308
+ (r'^[;\s]*curl\.cainfo\s*=.*', f'curl.cainfo = "{clean_cacert}"'),
309
+ (r'^[;\s]*openssl\.cafile\s*=.*', f'openssl.cafile = "{clean_cacert}"'),
310
+ ])
311
+
312
+ for pattern, repl in replacements:
313
+ text = re.sub(pattern, "", text, flags=re.MULTILINE)
314
+ text += f"\n{repl}"
315
+
316
+ # Clean up empty lines
317
+ text = re.sub(r"\n{3,}", "\n\n", text)
318
+ dst.write_text(text, encoding="utf-8")
319
+
320
+
321
+ def _kill_processes_in_dir(directory: Path) -> None:
322
+ """Find and terminate any active processes executing binaries from directory."""
323
+ import ctypes
324
+ clean_dir = str(directory.resolve()).lower()
325
+ count = 32768
326
+ pids = (ctypes.c_ulong * count)()
327
+ bytes_returned = ctypes.c_ulong()
328
+ if not ctypes.windll.psapi.EnumProcesses(ctypes.byref(pids), ctypes.sizeof(pids), ctypes.byref(bytes_returned)):
329
+ return
330
+ num_pids = bytes_returned.value // ctypes.sizeof(ctypes.c_ulong)
331
+ for i in range(num_pids):
332
+ pid = pids[i]
333
+ if pid <= 4:
334
+ continue
335
+ h = ctypes.windll.kernel32.OpenProcess(0x1000 | 0x0001, False, pid) # QUERY_LIMITED_INFO | TERMINATE
336
+ if not h:
337
+ continue
338
+ try:
339
+ buf = (ctypes.c_wchar * 1024)()
340
+ size = ctypes.c_ulong(1024)
341
+ if ctypes.windll.kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):
342
+ exe_path = buf.value.lower()
343
+ if exe_path.startswith(clean_dir):
344
+ ctypes.windll.kernel32.TerminateProcess(h, 0)
345
+ finally:
346
+ ctypes.windll.kernel32.CloseHandle(h)
347
+
348
+
349
+ def _safe_rmtree(path: Path, max_retries: int = 6, delay: float = 0.3) -> None:
350
+ """Robustly delete directory tree on Windows handling read-only attributes and transient locks."""
351
+ import gc, stat, time
352
+ if not path.exists():
353
+ return
354
+
355
+ def _on_error(fn, p, exc_info):
356
+ try:
357
+ os.chmod(p, stat.S_IWRITE | stat.S_IREAD)
358
+ fn(p)
359
+ except Exception:
360
+ pass
361
+
362
+ gc.collect()
363
+ for _ in range(max_retries):
364
+ try:
365
+ if hasattr(shutil, "rmtree") and "onexc" in shutil.rmtree.__code__.co_varnames:
366
+ shutil.rmtree(path, onexc=lambda fn, p, err: (os.chmod(p, 0o777), fn(p)))
367
+ else:
368
+ shutil.rmtree(path, onerror=_on_error)
369
+ return
370
+ except Exception:
371
+ time.sleep(delay)
372
+ gc.collect()
373
+
374
+ # Final fallback via cmd rmdir /s /q
375
+ try:
376
+ subprocess.run(["cmd.exe", "/c", "rmdir", "/s", "/q", str(path)], capture_output=True, timeout=10)
377
+ except Exception:
378
+ pass
379
+
380
+ if path.exists():
381
+ shutil.rmtree(path)
382
+
383
+
384
+ def uninstall(version: str) -> None:
385
+ """Stop any running pool and remove the installed PHP version."""
386
+ resolved_ver = version
387
+ try:
388
+ resolved_ver = resolve_installed(version)
389
+ except Exception:
390
+ pass
391
+
392
+ # Stop FastCGI pool for this version
393
+ from . import fcgi
394
+ try:
395
+ fcgi.stop(resolved_ver)
396
+ except Exception:
397
+ pass
398
+
399
+ # Stop phpMyAdmin if it was running on this PHP version
400
+ try:
401
+ from . import pma
402
+ pma_st = pma.status()
403
+ if pma_st and pma_st.get("php_version") == resolved_ver:
404
+ pma.stop()
405
+ except Exception:
406
+ pass
407
+
408
+ target = paths.version_dir(resolved_ver)
409
+ if not target.exists():
410
+ raise FileNotFoundError(f"PHP {version} is not installed")
411
+
412
+ # Terminate any remaining processes holding file locks in this directory
413
+ _kill_processes_in_dir(target)
414
+
415
+ # Safely remove the directory tree
416
+ _safe_rmtree(target)
417
+
418
+ # If this was the active version, clear current or switch to another
419
+ if paths.get_current_version() == resolved_ver:
420
+ remaining = list_installed()
421
+ if remaining:
422
+ use(remaining[-1])
423
+ else:
424
+ if paths.CURRENT_FILE.exists():
425
+ paths.CURRENT_FILE.unlink(missing_ok=True)
426
+ # Remove all PHP and Composer shims
427
+ for shim in [
428
+ "php.bat", "php.cmd", "php.ps1",
429
+ "php-cgi.bat", "php-cgi.cmd", "php-cgi.ps1",
430
+ "composer.bat", "composer.cmd", "composer.ps1",
431
+ ]:
432
+ (paths.SHIM_DIR / shim).unlink(missing_ok=True)
433
+
434
+
435
+ def list_installed() -> list[str]:
436
+ """Return sorted list of locally installed PHP versions."""
437
+ if not paths.PHP_DIR.exists():
438
+ return []
439
+ versions = [p.name for p in paths.PHP_DIR.iterdir() if p.is_dir() and (p / "php.exe").exists()]
440
+ return sorted(versions, key=_version_key)
441
+
442
+
443
+ def resolve_installed(version_query: str) -> str:
444
+ """Resolve an installed PHP version query (e.g. '8.4', '8.4.25', '8') to the exact installed version string."""
445
+ installed = list_installed()
446
+ if not installed:
447
+ raise FileNotFoundError("No PHP versions are currently installed. Run `ndev install <version>` first.")
448
+
449
+ q = version_query.strip().lower()
450
+ if q.startswith("php-"):
451
+ q = q[4:]
452
+ elif q.startswith("php"):
453
+ q = q[3:]
454
+
455
+ # Exact match first
456
+ for v in installed:
457
+ if v.lower() == q:
458
+ return v
459
+
460
+ # Prefix / major.minor match in reverse order (newest patch first)
461
+ matches = [
462
+ v for v in installed
463
+ if v == q or v.startswith(q + ".") or (len(v.split(".")) >= 2 and f"{v.split('.')[0]}.{v.split('.')[1]}" == q)
464
+ ]
465
+ if matches:
466
+ matches.sort(key=_version_key, reverse=True)
467
+ return matches[0]
468
+
469
+ raise FileNotFoundError(
470
+ f"PHP version matching '{version_query}' is not installed. Installed versions: {', '.join(installed)}"
471
+ )
472
+
473
+
474
+ def php_exe(version: str) -> Path:
475
+ target_ver = version
476
+ try:
477
+ target_ver = resolve_installed(version)
478
+ except Exception:
479
+ pass
480
+ return paths.version_dir(target_ver) / "php.exe"
481
+
482
+
483
+ def php_cgi_exe(version: str) -> Path:
484
+ target_ver = version
485
+ try:
486
+ target_ver = resolve_installed(version)
487
+ except Exception:
488
+ pass
489
+ return paths.version_dir(target_ver) / "php-cgi.exe"
490
+
491
+
492
+ def get_current_version() -> str | None:
493
+ return paths.get_current_version()
494
+
495
+
496
+ def use(version: str) -> str:
497
+ """
498
+ Point ndev shims (on PATH) at this version's php.exe and php-cgi.exe.
499
+ Generates php.bat, php.cmd, php-cgi.bat, and composer.bat in ~/.ndev/shims.
500
+ Returns the resolved version string.
501
+ """
502
+ resolved_ver = resolve_installed(version)
503
+ paths.ensure_dirs()
504
+
505
+ exe_path = str(paths.version_dir(resolved_ver) / "php.exe")
506
+ cgi_path = str(paths.version_dir(resolved_ver) / "php-cgi.exe")
507
+
508
+ # php.bat, php.cmd, php.ps1
509
+ (paths.SHIM_DIR / "php.bat").write_text(f'@echo off\r\n"{exe_path}" %*\r\n', encoding="utf-8")
510
+ (paths.SHIM_DIR / "php.cmd").write_text(f'@echo off\r\n"{exe_path}" %*\r\n', encoding="utf-8")
511
+ (paths.SHIM_DIR / "php.ps1").write_text(f'& "{exe_path}" @args\r\n', encoding="utf-8")
512
+
513
+ # php-cgi.bat, php-cgi.cmd, php-cgi.ps1
514
+ (paths.SHIM_DIR / "php-cgi.bat").write_text(f'@echo off\r\n"{cgi_path}" %*\r\n', encoding="utf-8")
515
+ (paths.SHIM_DIR / "php-cgi.cmd").write_text(f'@echo off\r\n"{cgi_path}" %*\r\n', encoding="utf-8")
516
+ (paths.SHIM_DIR / "php-cgi.ps1").write_text(f'& "{cgi_path}" @args\r\n', encoding="utf-8")
517
+
518
+ # If composer.phar is present, create/update composer shims
519
+ composer_phar = paths.SHIM_DIR / "composer.phar"
520
+ if composer_phar.exists():
521
+ clean_phar = str(composer_phar.resolve()).replace("\\", "/")
522
+ (paths.SHIM_DIR / "composer.bat").write_text(
523
+ f'@echo off\r\n"{exe_path}" "{clean_phar}" %*\r\n', encoding="utf-8"
524
+ )
525
+ (paths.SHIM_DIR / "composer.cmd").write_text(
526
+ f'@echo off\r\n"{exe_path}" "{clean_phar}" %*\r\n', encoding="utf-8"
527
+ )
528
+ (paths.SHIM_DIR / "composer.ps1").write_text(
529
+ f'& "{exe_path}" "{clean_phar}" @args\r\n', encoding="utf-8"
530
+ )
531
+
532
+ paths.set_current_version(resolved_ver)
533
+ return resolved_ver