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
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
|