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
File without changes
File without changes
ndev/win/core/db.py ADDED
@@ -0,0 +1,265 @@
1
+ """
2
+ MariaDB database/user management, shelling out to mysql.exe and mysqldump.exe.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from . import paths
13
+
14
+ DEFAULT_ROOT_PASSWORD = "root"
15
+ DEFAULT_HOST = "127.0.0.1"
16
+ DEFAULT_PORT = 3306
17
+
18
+
19
+ def _mysql_exe() -> str:
20
+ exe = paths.MARIADB_DIR / "bin" / "mysql.exe"
21
+ if exe.exists():
22
+ return str(exe)
23
+ system_exe = shutil.which("mysql") or shutil.which("mysql.exe")
24
+ if system_exe:
25
+ return system_exe
26
+ raise FileNotFoundError("MariaDB/MySQL client isn't installed -- run `ndev setup` first")
27
+
28
+
29
+ def _mysqldump_exe() -> str:
30
+ exe = paths.MARIADB_DIR / "bin" / "mysqldump.exe"
31
+ if exe.exists():
32
+ return str(exe)
33
+ system_exe = shutil.which("mysqldump") or shutil.which("mysqldump.exe")
34
+ if system_exe:
35
+ return system_exe
36
+ raise FileNotFoundError("mysqldump isn't installed -- run `ndev setup` first")
37
+
38
+
39
+ def run_sql(
40
+ sql: str,
41
+ root_password: str = DEFAULT_ROOT_PASSWORD,
42
+ host: str = DEFAULT_HOST,
43
+ port: int = DEFAULT_PORT,
44
+ user: str = "root",
45
+ ) -> str:
46
+ cmd = [
47
+ _mysql_exe(),
48
+ "-h", host,
49
+ "-P", str(port),
50
+ "-u", user,
51
+ "--connect-timeout=5",
52
+ f"--password={root_password}" if root_password else "",
53
+ "--batch",
54
+ "--skip-column-names",
55
+ "-e", sql,
56
+ ]
57
+ cmd = [arg for arg in cmd if arg]
58
+ result = subprocess.run(cmd, capture_output=True, text=True)
59
+ if result.returncode != 0:
60
+ err = result.stderr.strip()
61
+ if "Access denied for user" in err:
62
+ raise RuntimeError(f"Authentication failed: Access denied for '{user}'. Please verify your database password.")
63
+ raise RuntimeError(f"MySQL error: {err}")
64
+ return result.stdout
65
+
66
+
67
+ def test_connection(
68
+ root_password: str = DEFAULT_ROOT_PASSWORD,
69
+ host: str = DEFAULT_HOST,
70
+ port: int = DEFAULT_PORT,
71
+ user: str = "root",
72
+ ) -> tuple[bool, str]:
73
+ """Test connection to MariaDB and return (success, error_message)."""
74
+ try:
75
+ run_sql("SELECT 1;", root_password=root_password, host=host, port=port, user=user)
76
+ return True, ""
77
+ except Exception as e:
78
+ return False, str(e)
79
+
80
+
81
+ def _escape_sql_ident(ident: str) -> str:
82
+ """Safely escape a SQL identifier using backticks."""
83
+ clean = ident.replace("`", "``")
84
+ return f"`{clean}`"
85
+
86
+
87
+ def _escape_sql_string(val: str) -> str:
88
+ """Safely escape a SQL string literal."""
89
+ return val.replace("\\", "\\\\").replace("'", "''")
90
+
91
+
92
+ def create_db(
93
+ name: str,
94
+ owner: str = "",
95
+ user_host: str = "%",
96
+ charset: str = "utf8mb4",
97
+ collation: str = "utf8mb4_unicode_ci",
98
+ root_password: str = DEFAULT_ROOT_PASSWORD,
99
+ host: str = DEFAULT_HOST,
100
+ port: int = DEFAULT_PORT,
101
+ ) -> None:
102
+ db_ident = _escape_sql_ident(name)
103
+ sql = f"CREATE DATABASE IF NOT EXISTS {db_ident} CHARACTER SET {charset} COLLATE {collation};"
104
+ if owner:
105
+ owner_str = _escape_sql_string(owner)
106
+ host_str = _escape_sql_string(user_host)
107
+ sql += f" GRANT ALL PRIVILEGES ON {db_ident}.* TO '{owner_str}'@'{host_str}'; FLUSH PRIVILEGES;"
108
+ run_sql(sql, root_password=root_password, host=host, port=port)
109
+
110
+
111
+ def drop_db(
112
+ name: str,
113
+ root_password: str = DEFAULT_ROOT_PASSWORD,
114
+ host: str = DEFAULT_HOST,
115
+ port: int = DEFAULT_PORT,
116
+ ) -> None:
117
+ db_ident = _escape_sql_ident(name)
118
+ run_sql(f"DROP DATABASE IF EXISTS {db_ident};", root_password=root_password, host=host, port=port)
119
+
120
+
121
+ def export_db(
122
+ name: str,
123
+ output_path: Optional[str | Path] = None,
124
+ root_password: str = DEFAULT_ROOT_PASSWORD,
125
+ host: str = DEFAULT_HOST,
126
+ port: int = DEFAULT_PORT,
127
+ user: str = "root",
128
+ quick: bool = True,
129
+ single_transaction: bool = True,
130
+ routines: bool = True,
131
+ triggers: bool = True,
132
+ ) -> str:
133
+ cmd = [
134
+ _mysqldump_exe(),
135
+ "-h", host,
136
+ "-P", str(port),
137
+ "-u", user,
138
+ ]
139
+ if root_password:
140
+ cmd.append(f"--password={root_password}")
141
+ if quick:
142
+ cmd.append("--quick")
143
+ if single_transaction:
144
+ cmd.append("--single-transaction")
145
+ if routines:
146
+ cmd.append("--routines")
147
+ if triggers:
148
+ cmd.append("--triggers")
149
+ cmd.append(name)
150
+
151
+ if output_path:
152
+ out = Path(output_path).resolve()
153
+ out.parent.mkdir(parents=True, exist_ok=True)
154
+ with open(out, "w", encoding="utf-8") as f:
155
+ res = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
156
+ if res.returncode != 0:
157
+ raise RuntimeError(f"mysqldump error: {res.stderr.strip()}")
158
+ return str(out)
159
+ else:
160
+ res = subprocess.run(cmd, capture_output=True, text=True)
161
+ if res.returncode != 0:
162
+ raise RuntimeError(f"mysqldump error: {res.stderr.strip()}")
163
+ return res.stdout
164
+
165
+
166
+ def list_databases(
167
+ root_password: str = DEFAULT_ROOT_PASSWORD,
168
+ host: str = DEFAULT_HOST,
169
+ port: int = DEFAULT_PORT,
170
+ ) -> list[str]:
171
+ out = run_sql("SHOW DATABASES;", root_password=root_password, host=host, port=port)
172
+ lines = [line.strip() for line in out.strip().splitlines() if line.strip()]
173
+ system_dbs = {"information_schema", "mysql", "performance_schema", "sys"}
174
+ return [db for db in lines if db.lower() not in system_dbs]
175
+
176
+
177
+ def create_user(
178
+ username: str,
179
+ password: str,
180
+ grant_db: Optional[str] = None,
181
+ user_host: str = "localhost",
182
+ root_password: str = DEFAULT_ROOT_PASSWORD,
183
+ host: str = DEFAULT_HOST,
184
+ port: int = DEFAULT_PORT,
185
+ ) -> None:
186
+ u_str = _escape_sql_string(username)
187
+ p_str = _escape_sql_string(password)
188
+ h_str = _escape_sql_string(user_host)
189
+
190
+ hosts_to_create = [h_str]
191
+ if user_host in ("localhost", "127.0.0.1"):
192
+ hosts_to_create = ["localhost", "127.0.0.1", "%"]
193
+
194
+ sql_parts = []
195
+ for h in hosts_to_create:
196
+ sql_parts.append(f"CREATE USER IF NOT EXISTS '{u_str}'@'{h}' IDENTIFIED BY '{p_str}';")
197
+ if grant_db:
198
+ db_ident = _escape_sql_ident(grant_db)
199
+ sql_parts.append(f"GRANT ALL PRIVILEGES ON {db_ident}.* TO '{u_str}'@'{h}';")
200
+ sql_parts.append("FLUSH PRIVILEGES;")
201
+ run_sql(" ".join(sql_parts), root_password=root_password, host=host, port=port)
202
+
203
+
204
+ def drop_user(
205
+ username: str,
206
+ user_host: str = "localhost",
207
+ root_password: str = DEFAULT_ROOT_PASSWORD,
208
+ host: str = DEFAULT_HOST,
209
+ port: int = DEFAULT_PORT,
210
+ ) -> None:
211
+ u_str = _escape_sql_string(username)
212
+ h_str = _escape_sql_string(user_host)
213
+ hosts_to_drop = [h_str]
214
+ if user_host in ("localhost", "127.0.0.1"):
215
+ hosts_to_drop = ["localhost", "127.0.0.1", "%"]
216
+
217
+ sql_parts = []
218
+ for h in hosts_to_drop:
219
+ sql_parts.append(f"DROP USER IF EXISTS '{u_str}'@'{h}';")
220
+ sql_parts.append("FLUSH PRIVILEGES;")
221
+ run_sql(" ".join(sql_parts), root_password=root_password, host=host, port=port)
222
+
223
+
224
+ def list_users(
225
+ root_password: str = DEFAULT_ROOT_PASSWORD,
226
+ host: str = DEFAULT_HOST,
227
+ port: int = DEFAULT_PORT,
228
+ ) -> list[str]:
229
+ out = run_sql("SELECT CONCAT(User, '@', Host) FROM mysql.user;", root_password=root_password, host=host, port=port)
230
+ lines = [line.strip() for line in out.strip().splitlines() if line.strip()]
231
+ system_users = {"root@localhost", "root@127.0.0.1", "root@::1", "mariadb.sys@localhost", "mysql@localhost"}
232
+ return [u for u in lines if u.lower() not in system_users]
233
+
234
+
235
+ def import_db(
236
+ name: str,
237
+ sql_file_path: str | Path,
238
+ root_password: str = DEFAULT_ROOT_PASSWORD,
239
+ host: str = DEFAULT_HOST,
240
+ port: int = DEFAULT_PORT,
241
+ user: str = "root",
242
+ ) -> None:
243
+ """Import a SQL dump file into a database."""
244
+ file_path = Path(sql_file_path).resolve()
245
+ if not file_path.exists():
246
+ raise FileNotFoundError(f"SQL file does not exist: {file_path}")
247
+
248
+ # Ensure database exists before import
249
+ create_db(name, root_password=root_password, host=host, port=port)
250
+
251
+ cmd = [
252
+ _mysql_exe(),
253
+ "-h", host,
254
+ "-P", str(port),
255
+ "-u", user,
256
+ "--connect-timeout=5",
257
+ ]
258
+ if root_password:
259
+ cmd.append(f"--password={root_password}")
260
+ cmd.append(name)
261
+
262
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
263
+ res = subprocess.run(cmd, stdin=f, capture_output=True, text=True)
264
+ if res.returncode != 0:
265
+ raise RuntimeError(f"MySQL import error: {res.stderr.strip()}")
@@ -0,0 +1,94 @@
1
+ """
2
+ UAC elevation helpers for Windows.
3
+
4
+ ndev (Linux) shells out to `sudo` for vhost/setup/hosts-file work.
5
+ Windows has no equivalent shell primitive: elevation is a distinct
6
+ process launch via ShellExecuteW's "runas" verb, which triggers the
7
+ UAC prompt. There is no way to elevate an already-running process,
8
+ so commands that need admin rights either:
9
+
10
+ 1. Check is_admin() and relaunch themselves elevated, or
11
+ 2. Shell out to a small elevated helper for just the privileged
12
+ step (e.g. writing to the hosts file), keeping the rest of the
13
+ command running unprivileged.
14
+
15
+ Option 2 is used for hosts / vhost so the bulk of ndev
16
+ never needs to run elevated.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import ctypes
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+
26
+ def is_admin() -> bool:
27
+ try:
28
+ return ctypes.windll.shell32.IsUserAnAdmin() != 0
29
+ except Exception:
30
+ return False
31
+
32
+
33
+ def relaunch_as_admin(argv: list[str] | None = None) -> None:
34
+ """Re-invoke the current script elevated, then exit the caller."""
35
+ argv = argv or sys.argv
36
+ params = " ".join(f'"{a}"' for a in argv[1:])
37
+ ctypes.windll.shell32.ShellExecuteW(
38
+ None, "runas", sys.executable, f'"{argv[0]}" {params}', None, 1
39
+ )
40
+ sys.exit(0)
41
+
42
+
43
+ def run_elevated(command: list[str], wait: bool = True) -> int:
44
+ """
45
+ Run a single command elevated. If already running as Administrator,
46
+ executes directly without triggering a UAC prompt.
47
+ Otherwise uses ShellExecuteExW with the 'runas' verb.
48
+ """
49
+ if is_admin():
50
+ res = subprocess.run(command)
51
+ return res.returncode
52
+
53
+ exe, *args = command
54
+ params = " ".join(f'"{a}"' for a in args)
55
+ SEE_MASK_NOCLOSEPROCESS = 0x00000040
56
+
57
+ class SHELLEXECUTEINFO(ctypes.Structure):
58
+ _fields_ = [
59
+ ("cbSize", ctypes.c_ulong),
60
+ ("fMask", ctypes.c_ulong),
61
+ ("hwnd", ctypes.c_void_p),
62
+ ("lpVerb", ctypes.c_wchar_p),
63
+ ("lpFile", ctypes.c_wchar_p),
64
+ ("lpParameters", ctypes.c_wchar_p),
65
+ ("lpDirectory", ctypes.c_wchar_p),
66
+ ("nShow", ctypes.c_int),
67
+ ("hInstApp", ctypes.c_void_p),
68
+ ("lpIDList", ctypes.c_void_p),
69
+ ("lpClass", ctypes.c_wchar_p),
70
+ ("hkeyClass", ctypes.c_void_p),
71
+ ("dwHotKey", ctypes.c_ulong),
72
+ ("hIconOrMonitor", ctypes.c_void_p),
73
+ ("hProcess", ctypes.c_void_p),
74
+ ]
75
+
76
+ sei = SHELLEXECUTEINFO()
77
+ sei.cbSize = ctypes.sizeof(sei)
78
+ sei.fMask = SEE_MASK_NOCLOSEPROCESS
79
+ sei.lpVerb = "runas"
80
+ sei.lpFile = exe
81
+ sei.lpParameters = params
82
+ sei.nShow = 0
83
+
84
+ if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei)):
85
+ raise OSError("Elevation request was rejected or cancelled (UAC).")
86
+
87
+ if wait and sei.hProcess:
88
+ WAIT_INFINITE = 0xFFFFFFFF
89
+ ctypes.windll.kernel32.WaitForSingleObject(sei.hProcess, WAIT_INFINITE)
90
+ exit_code = ctypes.c_ulong(0)
91
+ ctypes.windll.kernel32.GetExitCodeProcess(sei.hProcess, ctypes.byref(exit_code))
92
+ ctypes.windll.kernel32.CloseHandle(sei.hProcess)
93
+ return exit_code.value
94
+ return 0
ndev/win/core/ext.py ADDED
@@ -0,0 +1,241 @@
1
+ """
2
+ PECL extension manager for Windows (precompiled DLL downloads from downloads.php.net).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import shutil
8
+ import urllib.error
9
+ import urllib.request
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from . import paths, php
15
+
16
+ PECL_RELEASES_BASE = "https://downloads.php.net/~windows/pecl/releases"
17
+ KNOWN_TOOLSETS = ["vs17", "vs16", "vc15", "vc14", "vc11"]
18
+
19
+
20
+ def _http_get_text(url: str) -> str:
21
+ req = urllib.request.Request(url, headers={"User-Agent": "ndev/0.1.0"})
22
+ with urllib.request.urlopen(req, timeout=30) as resp:
23
+ return resp.read().decode("utf-8", errors="ignore")
24
+
25
+
26
+ def list_ext_versions(ext: str) -> list[str]:
27
+ """Directory listing of available versions for a PECL extension."""
28
+ try:
29
+ html = _http_get_text(f"{PECL_RELEASES_BASE}/{ext}/")
30
+ except Exception as e:
31
+ raise RuntimeError(f"Could not fetch PECL listing for '{ext}': {e}")
32
+ raw_versions = set(re.findall(r'href="([\d][\w.\-]*)/"', html))
33
+ return sorted(raw_versions, key=php._version_key)
34
+
35
+
36
+ def _is_thread_safe(php_version: str) -> bool:
37
+ target_ver = php_version
38
+ try:
39
+ target_ver = php.resolve_installed(php_version)
40
+ except Exception:
41
+ pass
42
+ import subprocess
43
+ exe = php.php_exe(target_ver)
44
+ if exe.exists():
45
+ try:
46
+ out = subprocess.run([str(exe), "-i"], capture_output=True, text=True, timeout=10).stdout
47
+ if "Thread Safety => disabled" in out:
48
+ return False
49
+ if "Thread Safety => enabled" in out:
50
+ return True
51
+ except Exception:
52
+ pass
53
+ # ndev-win installs Thread Safe (TS) builds by default
54
+ return True
55
+
56
+
57
+ def find_release_zip(
58
+ ext: str,
59
+ ext_version: Optional[str],
60
+ php_version: str,
61
+ arch: str = "x64",
62
+ thread_safe: Optional[bool] = None,
63
+ ) -> str:
64
+ major_minor = ".".join(php_version.split(".")[:2])
65
+ is_ts = thread_safe if thread_safe is not None else _is_thread_safe(php_version)
66
+ ts_tag = "ts" if is_ts else "nts"
67
+
68
+ # If no extension version specified, search versions in descending order
69
+ versions = [ext_version] if ext_version else list_ext_versions(ext)[::-1]
70
+ if not versions:
71
+ raise FileNotFoundError(f"No versions found on PECL for extension '{ext}'")
72
+
73
+ for v in versions:
74
+ if not v:
75
+ continue
76
+ dir_url = f"{PECL_RELEASES_BASE}/{ext}/{v}/"
77
+ try:
78
+ html = _http_get_text(dir_url)
79
+ except Exception:
80
+ continue
81
+ available = set(re.findall(r'href="(php_[\w.\-]+\.zip)"', html))
82
+
83
+ for toolset in KNOWN_TOOLSETS:
84
+ name = f"php_{ext}-{v}-{major_minor}-{ts_tag}-{toolset}-{arch}.zip"
85
+ if name in available:
86
+ return dir_url + name
87
+ # Also try case-insensitive or without arch if matching
88
+ for avail_name in available:
89
+ if avail_name.lower() == name.lower():
90
+ return dir_url + avail_name
91
+
92
+ raise FileNotFoundError(
93
+ f"No precompiled {ext} build found for PHP {major_minor} ({ts_tag}, {arch}). "
94
+ f"Checked versions: {versions[:5]}"
95
+ )
96
+
97
+
98
+ def install(
99
+ ext: str,
100
+ ext_version: Optional[str],
101
+ php_version: str,
102
+ arch: str = "x64",
103
+ thread_safe: Optional[bool] = None,
104
+ ) -> Path:
105
+ zip_url = find_release_zip(ext, ext_version, php_version, arch, thread_safe)
106
+ zip_path = paths.DOWNLOADS_DIR / Path(zip_url).name
107
+ if not zip_path.exists() or zip_path.stat().st_size == 0:
108
+ req = urllib.request.Request(zip_url, headers={"User-Agent": "ndev/0.1.0"})
109
+ tmp = zip_path.with_suffix(".part")
110
+ try:
111
+ with urllib.request.urlopen(req, timeout=90) as resp, open(tmp, "wb") as f:
112
+ chunk_size = 65536
113
+ while True:
114
+ chunk = resp.read(chunk_size)
115
+ if not chunk:
116
+ break
117
+ f.write(chunk)
118
+ if tmp.exists() and tmp.stat().st_size > 0:
119
+ if zip_path.exists():
120
+ zip_path.unlink()
121
+ tmp.rename(zip_path)
122
+ else:
123
+ raise RuntimeError(f"Download from {zip_url} resulted in empty file.")
124
+ except Exception:
125
+ if tmp.exists():
126
+ tmp.unlink(missing_ok=True)
127
+ raise
128
+
129
+ ext_dir = paths.version_dir(php_version) / "ext"
130
+ ext_dir.mkdir(parents=True, exist_ok=True)
131
+
132
+ v_dir = paths.version_dir(php_version)
133
+ with zipfile.ZipFile(zip_path) as zf:
134
+ dll_names = [n for n in zf.namelist() if n.lower().endswith(".dll")]
135
+ if not dll_names:
136
+ raise RuntimeError(f"No .dll found inside {zip_path.name}")
137
+ for name in dll_names:
138
+ data = zf.read(name)
139
+ filename = Path(name).name
140
+ if filename.lower().startswith("php_"):
141
+ (ext_dir / filename).write_bytes(data)
142
+ else:
143
+ # Place runtime dependency DLLs (e.g. libssh2.dll) in PHP root for Windows DLL loader
144
+ (v_dir / filename).write_bytes(data)
145
+
146
+ enable(ext, php_version)
147
+ return ext_dir
148
+
149
+
150
+ def _ini_path(php_version: str) -> Path:
151
+ target_ver = php_version
152
+ try:
153
+ target_ver = php.resolve_installed(php_version)
154
+ except Exception:
155
+ pass
156
+ return paths.version_dir(target_ver) / "php.ini"
157
+
158
+
159
+ ZEND_EXTENSIONS = {"xdebug", "opcache"}
160
+
161
+
162
+ def enable(ext: str, php_version: str) -> None:
163
+ ini = _ini_path(php_version)
164
+ if not ini.exists():
165
+ return
166
+ text = ini.read_text(encoding="utf-8", errors="ignore")
167
+ directive = "zend_extension" if ext.lower() in ZEND_EXTENSIONS else "extension"
168
+
169
+ # Check if already enabled
170
+ pattern_active = rf"^\s*(?:extension|zend_extension)\s*=\s*(?:php_)?{re.escape(ext)}(?:\.dll)?\s*$"
171
+ if re.search(pattern_active, text, flags=re.MULTILINE):
172
+ return
173
+
174
+ # Check if disabled with semicolon
175
+ pattern_disabled = rf"^\s*;\s*(?:extension|zend_extension)\s*=\s*(?:php_)?{re.escape(ext)}(?:\.dll)?\s*$"
176
+ if re.search(pattern_disabled, text, flags=re.MULTILINE):
177
+ text = re.sub(pattern_disabled, f"{directive}={ext}", text, flags=re.MULTILINE)
178
+ else:
179
+ text += f"\n{directive}={ext}\n"
180
+
181
+ ini.write_text(text, encoding="utf-8")
182
+
183
+
184
+ def disable(ext: str, php_version: str) -> None:
185
+ ini = _ini_path(php_version)
186
+ if not ini.exists():
187
+ return
188
+ text = ini.read_text(encoding="utf-8", errors="ignore")
189
+ pattern_active = rf"^\s*((?:extension|zend_extension)\s*=\s*(?:php_)?{re.escape(ext)}(?:\.dll)?)\s*$"
190
+ if re.search(pattern_active, text, flags=re.MULTILINE):
191
+ text = re.sub(pattern_active, r";\1", text, flags=re.MULTILINE)
192
+ ini.write_text(text, encoding="utf-8")
193
+
194
+
195
+ def uninstall(ext: str, php_version: str) -> None:
196
+ target_ver = php_version
197
+ try:
198
+ target_ver = php.resolve_installed(php_version)
199
+ except Exception:
200
+ pass
201
+ disable(ext, target_ver)
202
+ ext_dir = paths.version_dir(target_ver) / "ext"
203
+ if ext_dir.exists():
204
+ for dll in ext_dir.glob(f"*{ext}*.dll"):
205
+ try:
206
+ dll.unlink()
207
+ except Exception:
208
+ pass
209
+
210
+
211
+ def list_status(php_version: str) -> dict[str, bool]:
212
+ """Returns {ext_name: enabled} for all detected extensions in php.ini and ext/ folder."""
213
+ target_ver = php_version
214
+ try:
215
+ target_ver = php.resolve_installed(php_version)
216
+ except Exception:
217
+ pass
218
+
219
+ status: dict[str, bool] = {}
220
+ ext_dir = paths.version_dir(target_ver) / "ext"
221
+ if ext_dir.exists():
222
+ for dll in ext_dir.glob("*.dll"):
223
+ name = dll.stem
224
+ if name.startswith("php_"):
225
+ name = name[4:]
226
+ status[name] = False
227
+
228
+ ini = _ini_path(target_ver)
229
+ if ini.exists():
230
+ for line in ini.read_text(encoding="utf-8", errors="ignore").splitlines():
231
+ line = line.strip()
232
+ m = re.match(r"^(;)?\s*(?:extension|zend_extension)\s*=\s*(?:php_)?(\S+?)(?:\.dll)?\s*$", line)
233
+ if m:
234
+ name = m.group(2)
235
+ # Filter out comment placeholders or paths in default template php.ini
236
+ if "/" in name or "\\" in name or name in ("modulename", "php_modulename", "dl_test"):
237
+ continue
238
+ is_enabled = m.group(1) is None
239
+ status[name] = is_enabled
240
+
241
+ return dict(sorted(status.items()))