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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import shutil
|
|
6
|
+
import typer
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from ndev.common.logger import logger
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
def chown_to_sudo_user(path: Path):
|
|
15
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
16
|
+
if sudo_user:
|
|
17
|
+
try:
|
|
18
|
+
import pwd
|
|
19
|
+
pw = pwd.getpwnam(sudo_user)
|
|
20
|
+
os.chown(str(path), pw.pw_uid, pw.pw_gid)
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def generate_local_cert(domain: str, certs_dir: Path) -> tuple[Path, Path]:
|
|
25
|
+
cert_path = certs_dir / f"{domain}.crt"
|
|
26
|
+
key_path = certs_dir / f"{domain}.key"
|
|
27
|
+
|
|
28
|
+
if cert_path.exists() and key_path.exists():
|
|
29
|
+
return cert_path, key_path
|
|
30
|
+
|
|
31
|
+
certs_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
chown_to_sudo_user(certs_dir)
|
|
33
|
+
|
|
34
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
35
|
+
if sudo_user:
|
|
36
|
+
cmd = [
|
|
37
|
+
"sudo", "-u", sudo_user,
|
|
38
|
+
"mkcert",
|
|
39
|
+
"-cert-file", str(cert_path),
|
|
40
|
+
"-key-file", str(key_path),
|
|
41
|
+
domain, f"*.{domain}"
|
|
42
|
+
]
|
|
43
|
+
else:
|
|
44
|
+
cmd = [
|
|
45
|
+
"mkcert",
|
|
46
|
+
"-cert-file", str(cert_path),
|
|
47
|
+
"-key-file", str(key_path),
|
|
48
|
+
domain, f"*.{domain}"
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
52
|
+
chown_to_sudo_user(key_path)
|
|
53
|
+
chown_to_sudo_user(cert_path)
|
|
54
|
+
|
|
55
|
+
return cert_path, key_path
|
|
56
|
+
|
|
57
|
+
def get_user_ndev_dir() -> Path:
|
|
58
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
59
|
+
if sudo_user:
|
|
60
|
+
try:
|
|
61
|
+
import pwd
|
|
62
|
+
return Path(pwd.getpwnam(sudo_user).pw_dir) / ".ndev"
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return Path(os.path.expanduser("~/.ndev"))
|
|
66
|
+
|
|
67
|
+
def get_installed_php_versions() -> list[dict]:
|
|
68
|
+
installed = []
|
|
69
|
+
ndev_dir = get_user_ndev_dir()
|
|
70
|
+
php_dir = ndev_dir / "php"
|
|
71
|
+
if php_dir.exists():
|
|
72
|
+
for path in php_dir.iterdir():
|
|
73
|
+
if path.is_dir():
|
|
74
|
+
ver = path.name
|
|
75
|
+
# get major/minor for socket
|
|
76
|
+
parts = ver.split(".")
|
|
77
|
+
if len(parts) >= 2:
|
|
78
|
+
mm = f"{parts[0]}{parts[1]}"
|
|
79
|
+
label = f"{parts[0]}.{parts[1]}"
|
|
80
|
+
else:
|
|
81
|
+
mm = ver
|
|
82
|
+
label = ver
|
|
83
|
+
sock = ndev_dir / "run" / f"php{mm}.sock"
|
|
84
|
+
|
|
85
|
+
# Check if it is running
|
|
86
|
+
pid_file = ndev_dir / "run" / f"php-fpm-{mm}.pid"
|
|
87
|
+
from ndev.linux.runtime.process import is_pid_running, read_pid_file
|
|
88
|
+
pid = read_pid_file(pid_file)
|
|
89
|
+
is_running = is_pid_running(pid) if pid else False
|
|
90
|
+
|
|
91
|
+
installed.append({
|
|
92
|
+
"version": ver,
|
|
93
|
+
"label": label,
|
|
94
|
+
"socket": sock,
|
|
95
|
+
"running": is_running
|
|
96
|
+
})
|
|
97
|
+
# Sort by version
|
|
98
|
+
from packaging.version import parse as parse_version
|
|
99
|
+
try:
|
|
100
|
+
installed.sort(key=lambda x: parse_version(x["version"]))
|
|
101
|
+
except Exception:
|
|
102
|
+
installed.sort(key=lambda x: x["version"])
|
|
103
|
+
return installed
|
|
104
|
+
|
|
105
|
+
def vhost_cmd(
|
|
106
|
+
domain: str = typer.Option(None, "--domain", "-d", help="Domain (e.g. project.local)"),
|
|
107
|
+
root: str = typer.Option(None, "--root", "-r", help="Project Root Directory"),
|
|
108
|
+
php: str = typer.Option(None, "--php", "-p", help="PHP socket alias, version, or index"),
|
|
109
|
+
ssl: Optional[bool] = typer.Option(None, "--ssl/--no-ssl", help="Enable SSL/HTTPS with local certificate generation")
|
|
110
|
+
):
|
|
111
|
+
"""Create Nginx Virtual Host config, map to hosts file, and reload Nginx."""
|
|
112
|
+
if not domain:
|
|
113
|
+
domain = typer.prompt("Domain (e.g. project.local)").strip()
|
|
114
|
+
if not domain:
|
|
115
|
+
logger.error("Domain is required.")
|
|
116
|
+
raise typer.Exit(code=1)
|
|
117
|
+
|
|
118
|
+
if not root:
|
|
119
|
+
root = typer.prompt("Project Root").strip()
|
|
120
|
+
if not root:
|
|
121
|
+
logger.error("Project Root is required.")
|
|
122
|
+
raise typer.Exit(code=1)
|
|
123
|
+
|
|
124
|
+
root_path = Path(root)
|
|
125
|
+
if not root_path.exists() or not root_path.is_dir():
|
|
126
|
+
logger.error(f"Project root directory does not exist: {root}")
|
|
127
|
+
raise typer.Exit(code=1)
|
|
128
|
+
|
|
129
|
+
installed_phps = get_installed_php_versions()
|
|
130
|
+
if not installed_phps:
|
|
131
|
+
logger.error("No installed PHP versions found. Please install PHP using 'ndev install <version>' first.")
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
|
|
134
|
+
selected_sock = None
|
|
135
|
+
selected_ver = None
|
|
136
|
+
if php:
|
|
137
|
+
# Check if it is a socket path
|
|
138
|
+
php_path = Path(php)
|
|
139
|
+
if php_path.exists() and php_path.suffix == ".sock":
|
|
140
|
+
selected_sock = php_path
|
|
141
|
+
else:
|
|
142
|
+
clean_php = php.lower()
|
|
143
|
+
if clean_php.startswith("ndev "):
|
|
144
|
+
clean_php = clean_php[len("ndev "):]
|
|
145
|
+
|
|
146
|
+
for item in installed_phps:
|
|
147
|
+
if clean_php == item["label"].lower() or clean_php == item["version"].lower():
|
|
148
|
+
selected_sock = item["socket"]
|
|
149
|
+
selected_ver = item["version"]
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
if not selected_sock:
|
|
153
|
+
try:
|
|
154
|
+
idx = int(php)
|
|
155
|
+
if 1 <= idx <= len(installed_phps):
|
|
156
|
+
selected_sock = installed_phps[idx - 1]["socket"]
|
|
157
|
+
selected_ver = installed_phps[idx - 1]["version"]
|
|
158
|
+
except ValueError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
if not selected_sock:
|
|
162
|
+
logger.error(f"Invalid PHP selection: {php}")
|
|
163
|
+
raise typer.Exit(code=1)
|
|
164
|
+
else:
|
|
165
|
+
console.print("\n[bold]Available PHP Versions[/bold]")
|
|
166
|
+
console.print("----------------------")
|
|
167
|
+
for i, item in enumerate(installed_phps):
|
|
168
|
+
status = "[green]Running[/green]" if item["running"] else "[yellow]Stopped[/yellow]"
|
|
169
|
+
console.print(f" {i + 1}) PHP {item['version']} ({item['label']}) - {status}")
|
|
170
|
+
console.print("")
|
|
171
|
+
choice = typer.prompt("Select PHP version index", type=int)
|
|
172
|
+
if choice < 1 or choice > len(installed_phps):
|
|
173
|
+
logger.error("Invalid selection.")
|
|
174
|
+
raise typer.Exit(code=1)
|
|
175
|
+
selected_sock = installed_phps[choice - 1]["socket"]
|
|
176
|
+
selected_ver = installed_phps[choice - 1]["version"]
|
|
177
|
+
|
|
178
|
+
if ssl is None:
|
|
179
|
+
ssl = typer.confirm("Enable SSL/HTTPS?", default=False)
|
|
180
|
+
|
|
181
|
+
# If selected PHP version is stopped, start it
|
|
182
|
+
if selected_ver:
|
|
183
|
+
selected_item = next((item for item in installed_phps if item["version"] == selected_ver), None)
|
|
184
|
+
if selected_item and not selected_item["running"]:
|
|
185
|
+
console.print(f"[yellow]PHP-FPM {selected_ver} is stopped. Starting it...[/yellow]")
|
|
186
|
+
try:
|
|
187
|
+
from ndev.linux.runtime.fpm import start_fpm
|
|
188
|
+
start_fpm(selected_ver)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.warning(f"Could not automatically start PHP-FPM: {e}")
|
|
191
|
+
|
|
192
|
+
cert_path, key_path = None, None
|
|
193
|
+
if ssl:
|
|
194
|
+
if not shutil.which("mkcert"):
|
|
195
|
+
logger.error("mkcert binary not found. Please install mkcert to generate local certificates.")
|
|
196
|
+
raise typer.Exit(code=1)
|
|
197
|
+
certs_dir = get_user_ndev_dir() / "certs"
|
|
198
|
+
try:
|
|
199
|
+
cert_path, key_path = generate_local_cert(domain, certs_dir)
|
|
200
|
+
console.print(f"Generated SSL Certificates:")
|
|
201
|
+
console.print(f" Cert: {cert_path}")
|
|
202
|
+
console.print(f" Key : {key_path}")
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error(f"Failed to generate SSL certificate: {e}")
|
|
205
|
+
raise typer.Exit(code=1)
|
|
206
|
+
|
|
207
|
+
if os.geteuid() != 0:
|
|
208
|
+
console.print("\n[bold yellow]Privileged operations required. Elevating via sudo...[/bold yellow]")
|
|
209
|
+
cmd = [
|
|
210
|
+
"sudo",
|
|
211
|
+
sys.executable,
|
|
212
|
+
"-m",
|
|
213
|
+
"ndev",
|
|
214
|
+
"vhost",
|
|
215
|
+
"--domain",
|
|
216
|
+
domain,
|
|
217
|
+
"--root",
|
|
218
|
+
root,
|
|
219
|
+
"--php",
|
|
220
|
+
str(selected_sock)
|
|
221
|
+
]
|
|
222
|
+
if ssl:
|
|
223
|
+
cmd.append("--ssl")
|
|
224
|
+
else:
|
|
225
|
+
cmd.append("--no-ssl")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
res = subprocess.run(cmd)
|
|
229
|
+
raise typer.Exit(code=res.returncode)
|
|
230
|
+
except KeyboardInterrupt:
|
|
231
|
+
raise typer.Exit(code=1)
|
|
232
|
+
|
|
233
|
+
nginx_available = Path("/etc/nginx/sites-available")
|
|
234
|
+
nginx_enabled = Path("/etc/nginx/sites-enabled")
|
|
235
|
+
hosts_file = Path("/etc/hosts")
|
|
236
|
+
|
|
237
|
+
if not nginx_available.exists() or not nginx_enabled.exists():
|
|
238
|
+
logger.error("Nginx configuration directories not found.")
|
|
239
|
+
raise typer.Exit(code=1)
|
|
240
|
+
|
|
241
|
+
conf_file = nginx_available / f"{domain}.conf"
|
|
242
|
+
|
|
243
|
+
if ssl:
|
|
244
|
+
config_template = f"""server {{
|
|
245
|
+
listen 80;
|
|
246
|
+
listen [::]:80;
|
|
247
|
+
server_name {domain};
|
|
248
|
+
return 301 https://$host$request_uri;
|
|
249
|
+
}}
|
|
250
|
+
|
|
251
|
+
server {{
|
|
252
|
+
listen 443 ssl;
|
|
253
|
+
listen [::]:443 ssl;
|
|
254
|
+
|
|
255
|
+
server_name {domain};
|
|
256
|
+
|
|
257
|
+
ssl_certificate {cert_path};
|
|
258
|
+
ssl_certificate_key {key_path};
|
|
259
|
+
|
|
260
|
+
root {root};
|
|
261
|
+
index index.php index.html index.htm;
|
|
262
|
+
|
|
263
|
+
access_log /var/log/nginx/{domain}.access.log;
|
|
264
|
+
error_log /var/log/nginx/{domain}.error.log;
|
|
265
|
+
|
|
266
|
+
location / {{
|
|
267
|
+
try_files $uri $uri/ /index.php?$query_string;
|
|
268
|
+
}}
|
|
269
|
+
|
|
270
|
+
location ~ \\.php$ {{
|
|
271
|
+
include snippets/fastcgi-php.conf;
|
|
272
|
+
fastcgi_pass unix:{selected_sock};
|
|
273
|
+
}}
|
|
274
|
+
|
|
275
|
+
location ~ /\\.ht {{
|
|
276
|
+
deny all;
|
|
277
|
+
}}
|
|
278
|
+
}}
|
|
279
|
+
"""
|
|
280
|
+
else:
|
|
281
|
+
config_template = f"""server {{
|
|
282
|
+
listen 80;
|
|
283
|
+
listen [::]:80;
|
|
284
|
+
|
|
285
|
+
server_name {domain};
|
|
286
|
+
|
|
287
|
+
root {root};
|
|
288
|
+
index index.php index.html index.htm;
|
|
289
|
+
|
|
290
|
+
access_log /var/log/nginx/{domain}.access.log;
|
|
291
|
+
error_log /var/log/nginx/{domain}.error.log;
|
|
292
|
+
|
|
293
|
+
location / {{
|
|
294
|
+
try_files $uri $uri/ /index.php?$query_string;
|
|
295
|
+
}}
|
|
296
|
+
|
|
297
|
+
location ~ \\.php$ {{
|
|
298
|
+
include snippets/fastcgi-php.conf;
|
|
299
|
+
fastcgi_pass unix:{selected_sock};
|
|
300
|
+
}}
|
|
301
|
+
|
|
302
|
+
location ~ /\\.ht {{
|
|
303
|
+
deny all;
|
|
304
|
+
}}
|
|
305
|
+
}}
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
conf_file.write_text(config_template)
|
|
310
|
+
enabled_link = nginx_enabled / f"{domain}.conf"
|
|
311
|
+
if enabled_link.exists() or enabled_link.is_symlink():
|
|
312
|
+
enabled_link.unlink()
|
|
313
|
+
enabled_link.symlink_to(conf_file)
|
|
314
|
+
|
|
315
|
+
hosts_content = hosts_file.read_text()
|
|
316
|
+
pattern = rf"^\s*127\.0\.0\.1\s+.*\b{re.escape(domain)}\b"
|
|
317
|
+
found = False
|
|
318
|
+
for line in hosts_content.splitlines():
|
|
319
|
+
if re.match(pattern, line):
|
|
320
|
+
found = True
|
|
321
|
+
break
|
|
322
|
+
if not found:
|
|
323
|
+
with hosts_file.open("a") as f:
|
|
324
|
+
f.write(f"\n127.0.0.1 {domain}\n")
|
|
325
|
+
|
|
326
|
+
res = subprocess.run(["nginx", "-t"], capture_output=True, text=True)
|
|
327
|
+
if res.returncode != 0:
|
|
328
|
+
logger.error(f"Nginx config test failed:\n{res.stderr}")
|
|
329
|
+
enabled_link.unlink(missing_ok=True)
|
|
330
|
+
conf_file.unlink(missing_ok=True)
|
|
331
|
+
raise typer.Exit(code=1)
|
|
332
|
+
|
|
333
|
+
subprocess.run(["systemctl", "reload", "nginx"], check=True)
|
|
334
|
+
|
|
335
|
+
console.print(f"\n[bold green]VHost Created Successfully[/bold green]")
|
|
336
|
+
console.print("--------------------------")
|
|
337
|
+
console.print(f"Domain : {domain}")
|
|
338
|
+
console.print(f"Root : {root}")
|
|
339
|
+
console.print(f"PHP Socket : {selected_sock}")
|
|
340
|
+
console.print(f"Config : {conf_file}")
|
|
341
|
+
if ssl:
|
|
342
|
+
console.print(f"SSL Cert : {cert_path}")
|
|
343
|
+
console.print(f"SSL Key : {key_path}")
|
|
344
|
+
console.print(f"\nOpen: https://{domain}")
|
|
345
|
+
else:
|
|
346
|
+
console.print(f"\nOpen: http://{domain}")
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.error(f"Failed to create virtual host: {e}")
|
|
349
|
+
raise typer.Exit(code=1)
|
|
350
|
+
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tarfile
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from ndev.common.constants import BUILDS_DIR, PHP_DIR
|
|
6
|
+
from ndev.common.logger import logger
|
|
7
|
+
from ndev.linux.chroot.manager import SandboxManager
|
|
8
|
+
from ndev.common.config import load_config
|
|
9
|
+
from ndev.common.manifest import add_installed_version
|
|
10
|
+
|
|
11
|
+
def extract_archive(archive_path: Path, extract_dir: Path) -> Path:
|
|
12
|
+
"""Extract a tarball to a given directory and return the extracted folder path."""
|
|
13
|
+
logger.info(f"Extracting {archive_path.name} to {extract_dir}...")
|
|
14
|
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
with tarfile.open(archive_path) as tar:
|
|
17
|
+
root_dir_name = None
|
|
18
|
+
for member in tar.getmembers():
|
|
19
|
+
parts = Path(member.name).parts
|
|
20
|
+
if parts and parts[0] != ".":
|
|
21
|
+
root_dir_name = parts[0]
|
|
22
|
+
break
|
|
23
|
+
|
|
24
|
+
if not root_dir_name:
|
|
25
|
+
raise ValueError("Could not find a valid root directory in the tarball.")
|
|
26
|
+
|
|
27
|
+
tar.extractall(path=extract_dir)
|
|
28
|
+
|
|
29
|
+
return extract_dir / root_dir_name
|
|
30
|
+
|
|
31
|
+
def apply_patches(version: str, build_dir: Path):
|
|
32
|
+
"""Apply patches for a specific PHP version from the patches directory."""
|
|
33
|
+
major_minor = ".".join(version.split(".")[:2])
|
|
34
|
+
|
|
35
|
+
project_root = Path(__file__).resolve().parents[2]
|
|
36
|
+
patches_dirs = [
|
|
37
|
+
project_root / "patches" / version,
|
|
38
|
+
project_root / "patches" / major_minor,
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
import subprocess
|
|
42
|
+
|
|
43
|
+
applied_any = False
|
|
44
|
+
for patch_dir in patches_dirs:
|
|
45
|
+
if patch_dir.exists() and patch_dir.is_dir():
|
|
46
|
+
patch_files = sorted(patch_dir.glob("*.patch")) + sorted(patch_dir.glob("*.diff"))
|
|
47
|
+
seen = set()
|
|
48
|
+
for patch_file in patch_files:
|
|
49
|
+
if patch_file.name in seen:
|
|
50
|
+
continue
|
|
51
|
+
seen.add(patch_file.name)
|
|
52
|
+
|
|
53
|
+
logger.info(f"Applying patch {patch_file.name} for PHP {version}...")
|
|
54
|
+
try:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["patch", "-p1", "-N", "-t", "-i", str(patch_file)],
|
|
57
|
+
cwd=build_dir,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
check=True
|
|
61
|
+
)
|
|
62
|
+
applied_any = True
|
|
63
|
+
except subprocess.CalledProcessError as e:
|
|
64
|
+
logger.error(f"Failed to apply patch {patch_file.name}: {e.stderr}")
|
|
65
|
+
raise e
|
|
66
|
+
|
|
67
|
+
if not applied_any:
|
|
68
|
+
logger.debug(f"No patches found for PHP {version}.")
|
|
69
|
+
|
|
70
|
+
def load_compat_args(version: str) -> str:
|
|
71
|
+
"""Load compatibility compiler flags from the args directory for the given version."""
|
|
72
|
+
major_minor = ".".join(version.split(".")[:2])
|
|
73
|
+
|
|
74
|
+
project_root = Path(__file__).resolve().parents[2]
|
|
75
|
+
args_files = [
|
|
76
|
+
project_root / "args" / f"{version}.txt",
|
|
77
|
+
project_root / "args" / f"{major_minor}.txt",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for args_file in args_files:
|
|
81
|
+
if args_file.exists() and args_file.is_file():
|
|
82
|
+
try:
|
|
83
|
+
content = args_file.read_text().strip()
|
|
84
|
+
return " " + " ".join(content.split())
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(f"Failed to read args file {args_file}: {e}")
|
|
87
|
+
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
def build_php(version: str, archive_path: Path, show_logs: bool = False) -> Path:
|
|
91
|
+
"""Extract, configure, compile and install PHP inside bubblewrap."""
|
|
92
|
+
build_dir = BUILDS_DIR / f"php-{version}"
|
|
93
|
+
if build_dir.exists():
|
|
94
|
+
shutil.rmtree(build_dir)
|
|
95
|
+
|
|
96
|
+
extracted_path = extract_archive(archive_path, BUILDS_DIR)
|
|
97
|
+
if extracted_path.resolve() != build_dir.resolve():
|
|
98
|
+
logger.info(f"Renaming build directory from {extracted_path.name} to {build_dir.name}")
|
|
99
|
+
shutil.move(str(extracted_path), str(build_dir))
|
|
100
|
+
|
|
101
|
+
# Apply version-specific patches
|
|
102
|
+
apply_patches(version, build_dir)
|
|
103
|
+
|
|
104
|
+
config = load_config()
|
|
105
|
+
flags = config.get("build", {}).get("configure_flags", [])
|
|
106
|
+
|
|
107
|
+
install_prefix = PHP_DIR / version
|
|
108
|
+
if install_prefix.exists():
|
|
109
|
+
logger.info(f"Removing existing installation at {install_prefix}")
|
|
110
|
+
shutil.rmtree(install_prefix)
|
|
111
|
+
|
|
112
|
+
configure_args = [
|
|
113
|
+
"./configure",
|
|
114
|
+
f"--prefix={install_prefix}",
|
|
115
|
+
f"--with-config-file-path={install_prefix}/etc",
|
|
116
|
+
f"--with-config-file-scan-dir={install_prefix}/etc/conf.d",
|
|
117
|
+
] + flags
|
|
118
|
+
|
|
119
|
+
env = os.environ.copy()
|
|
120
|
+
# Avoid pkg-config dependency checks for libraries inside the sandbox
|
|
121
|
+
env["CURL_CFLAGS"] = "-I/usr/local/include"
|
|
122
|
+
env["CURL_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lcurl"
|
|
123
|
+
|
|
124
|
+
env["WEBP_CFLAGS"] = "-I/usr/local/include"
|
|
125
|
+
env["WEBP_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lwebp"
|
|
126
|
+
|
|
127
|
+
env["JPEG_CFLAGS"] = "-I/usr/local/include"
|
|
128
|
+
env["JPEG_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -ljpeg"
|
|
129
|
+
|
|
130
|
+
env["PNG_CFLAGS"] = "-I/usr/local/include"
|
|
131
|
+
env["PNG_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lpng"
|
|
132
|
+
|
|
133
|
+
env["FREETYPE2_CFLAGS"] = "-I/usr/local/include/freetype2 -I/usr/local/include"
|
|
134
|
+
env["FREETYPE2_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lfreetype"
|
|
135
|
+
|
|
136
|
+
env["LIBSODIUM_CFLAGS"] = "-I/usr/local/include"
|
|
137
|
+
env["LIBSODIUM_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lsodium"
|
|
138
|
+
|
|
139
|
+
env["LIBZIP_CFLAGS"] = "-I/usr/local/include"
|
|
140
|
+
env["LIBZIP_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lzip"
|
|
141
|
+
|
|
142
|
+
env["ONIG_CFLAGS"] = "-I/usr/local/include"
|
|
143
|
+
env["ONIG_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -lonig"
|
|
144
|
+
|
|
145
|
+
env["ICU_CFLAGS"] = "-I/usr/local/include"
|
|
146
|
+
env["ICU_LIBS"] = "-L/usr/local/lib -L/usr/local/lib/x86_64-linux-gnu -licui18n -licuuc -licudata -licuio"
|
|
147
|
+
|
|
148
|
+
compat_flags = load_compat_args(version)
|
|
149
|
+
if compat_flags:
|
|
150
|
+
logger.info(f"Applying compatibility flags from args file for PHP {version}...")
|
|
151
|
+
env["CFLAGS"] = env.get("CFLAGS", "") + compat_flags
|
|
152
|
+
env["CXXFLAGS"] = env.get("CXXFLAGS", "") + compat_flags
|
|
153
|
+
|
|
154
|
+
if not show_logs:
|
|
155
|
+
logger.info(f"[yellow]Compiling PHP {version}...[/yellow]")
|
|
156
|
+
else:
|
|
157
|
+
logger.info("Configuring PHP within sandbox...")
|
|
158
|
+
sandbox = SandboxManager()
|
|
159
|
+
sandbox.run(configure_args, cwd=build_dir, env=env, show_logs=show_logs)
|
|
160
|
+
|
|
161
|
+
cores = os.cpu_count() or 2
|
|
162
|
+
if show_logs:
|
|
163
|
+
logger.info(f"Compiling PHP using {cores} parallel jobs...")
|
|
164
|
+
try:
|
|
165
|
+
sandbox.run(["make", f"-j{cores}"], cwd=build_dir, env=env, show_logs=show_logs)
|
|
166
|
+
except Exception:
|
|
167
|
+
logger.warning("Parallel compilation failed (likely due to a libtool race condition). Retrying sequentially...")
|
|
168
|
+
sandbox.run(["make"], cwd=build_dir, env=env, show_logs=show_logs)
|
|
169
|
+
|
|
170
|
+
if not show_logs:
|
|
171
|
+
logger.info(f"[green]Compiled PHP {version}[/green]")
|
|
172
|
+
logger.info(f"[yellow]Installing PHP {version}...[/yellow]")
|
|
173
|
+
else:
|
|
174
|
+
logger.info("Installing PHP...")
|
|
175
|
+
sandbox.run(["make", "install"], cwd=build_dir, env=env, show_logs=show_logs)
|
|
176
|
+
|
|
177
|
+
if not show_logs:
|
|
178
|
+
logger.info(f"[green]Installed PHP {version}[/green]")
|
|
179
|
+
|
|
180
|
+
add_installed_version(version, str(install_prefix), configure_args)
|
|
181
|
+
|
|
182
|
+
logger.info(f"PHP {version} compiled and installed successfully at {install_prefix}!")
|
|
183
|
+
return install_prefix
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import httpx
|
|
4
|
+
from rich.progress import Progress, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn
|
|
5
|
+
from ndev.common.constants import DOWNLOADS_DIR
|
|
6
|
+
from ndev.common.logger import logger
|
|
7
|
+
|
|
8
|
+
def calculate_sha256(filepath: Path) -> str:
|
|
9
|
+
"""Calculate the SHA-256 checksum of a file."""
|
|
10
|
+
sha256 = hashlib.sha256()
|
|
11
|
+
with open(filepath, "rb") as f:
|
|
12
|
+
while chunk := f.read(8192):
|
|
13
|
+
sha256.update(chunk)
|
|
14
|
+
return sha256.hexdigest()
|
|
15
|
+
|
|
16
|
+
def download_php_source(filename: str, sha256_expected: str, download_url: str) -> Path:
|
|
17
|
+
"""Download PHP source tarball and verify its SHA-256 checksum."""
|
|
18
|
+
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
target_path = DOWNLOADS_DIR / filename
|
|
20
|
+
|
|
21
|
+
if target_path.exists():
|
|
22
|
+
logger.info(f"Checking cached download: {filename}")
|
|
23
|
+
checksum = calculate_sha256(target_path)
|
|
24
|
+
if checksum == sha256_expected:
|
|
25
|
+
logger.info("Cache hit: Download is valid.")
|
|
26
|
+
return target_path
|
|
27
|
+
else:
|
|
28
|
+
logger.warning("Cache mismatch: Re-downloading source...")
|
|
29
|
+
target_path.unlink()
|
|
30
|
+
|
|
31
|
+
logger.info(f"Downloading {filename} from {download_url}...")
|
|
32
|
+
|
|
33
|
+
with httpx.Client() as client:
|
|
34
|
+
with client.stream("GET", download_url) as response:
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
37
|
+
|
|
38
|
+
with Progress(
|
|
39
|
+
TextColumn("[bold blue]{task.description}"),
|
|
40
|
+
BarColumn(),
|
|
41
|
+
DownloadColumn(),
|
|
42
|
+
TransferSpeedColumn(),
|
|
43
|
+
TimeRemainingColumn()
|
|
44
|
+
) as progress:
|
|
45
|
+
task = progress.add_task("Downloading", total=total_size)
|
|
46
|
+
|
|
47
|
+
with open(target_path, "wb") as f:
|
|
48
|
+
for chunk in response.iter_bytes(chunk_size=8192):
|
|
49
|
+
f.write(chunk)
|
|
50
|
+
progress.update(task, advance=len(chunk))
|
|
51
|
+
|
|
52
|
+
checksum = calculate_sha256(target_path)
|
|
53
|
+
if sha256_expected and checksum != sha256_expected:
|
|
54
|
+
target_path.unlink()
|
|
55
|
+
raise ValueError(f"SHA-256 checksum mismatch for {filename}. Expected {sha256_expected}, got {checksum}")
|
|
56
|
+
|
|
57
|
+
logger.info("Download completed and verified successfully.")
|
|
58
|
+
return target_path
|