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
|
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()}")
|
ndev/win/core/elevate.py
ADDED
|
@@ -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()))
|