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/__init__.py
ADDED
ndev/__main__.py
ADDED
ndev/cli.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-platform CLI entrypoint for ndev.
|
|
3
|
+
Auto-detects the host operating system (Windows vs. Linux/POSIX)
|
|
4
|
+
and dispatches commands to the appropriate platform runtime engine.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
# Ensure UTF-8 output on Windows consoles to prevent glyph corruption
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
14
|
+
try:
|
|
15
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
16
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
from ndev.main import main, app
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
ndev/common/__init__.py
ADDED
ndev/common/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ndev.common.constants import (
|
|
5
|
+
NDEV_DIR, CACHE_DIR, DOWNLOADS_DIR, BUILDS_DIR, CHROOT_DIR,
|
|
6
|
+
LOGS_DIR, RUN_DIR, PHP_DIR, CERTS_DIR, CONFIG_FILE, DEFAULT_CONFIG
|
|
7
|
+
)
|
|
8
|
+
from ndev.common.logger import logger
|
|
9
|
+
|
|
10
|
+
def init_layout():
|
|
11
|
+
"""Create all required directories in ~/.ndev if they do not exist."""
|
|
12
|
+
for directory in [NDEV_DIR, CACHE_DIR, DOWNLOADS_DIR, BUILDS_DIR, CHROOT_DIR, LOGS_DIR, RUN_DIR, PHP_DIR, CERTS_DIR]:
|
|
13
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
if not CONFIG_FILE.exists():
|
|
16
|
+
CONFIG_FILE.write_text(DEFAULT_CONFIG)
|
|
17
|
+
logger.info(f"Initialized default configuration in {CONFIG_FILE}")
|
|
18
|
+
else:
|
|
19
|
+
try:
|
|
20
|
+
content = CONFIG_FILE.read_text()
|
|
21
|
+
new_flags = [
|
|
22
|
+
"--enable-fpm",
|
|
23
|
+
"--enable-mbstring",
|
|
24
|
+
"--enable-xml",
|
|
25
|
+
"--with-openssl",
|
|
26
|
+
"--with-zlib",
|
|
27
|
+
"--enable-pdo",
|
|
28
|
+
"--with-pdo-mysql",
|
|
29
|
+
"--with-mysqli",
|
|
30
|
+
"--with-curl",
|
|
31
|
+
"--enable-bcmath",
|
|
32
|
+
"--enable-calendar",
|
|
33
|
+
"--enable-exif",
|
|
34
|
+
"--enable-ftp",
|
|
35
|
+
"--enable-intl",
|
|
36
|
+
"--enable-pcntl",
|
|
37
|
+
"--enable-sockets",
|
|
38
|
+
"--enable-opcache",
|
|
39
|
+
"--enable-soap",
|
|
40
|
+
"--enable-gd",
|
|
41
|
+
"--with-jpeg",
|
|
42
|
+
"--with-webp",
|
|
43
|
+
"--with-freetype",
|
|
44
|
+
"--with-zip",
|
|
45
|
+
"--with-sodium",
|
|
46
|
+
"--with-bz2",
|
|
47
|
+
"--with-gmp",
|
|
48
|
+
"--with-readline"
|
|
49
|
+
]
|
|
50
|
+
missing_flags = [f for f in new_flags if f not in content]
|
|
51
|
+
if "configure_flags" in content and missing_flags:
|
|
52
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
53
|
+
config = tomllib.load(f)
|
|
54
|
+
build_sec = config.setdefault("build", {})
|
|
55
|
+
flags = build_sec.setdefault("configure_flags", [])
|
|
56
|
+
|
|
57
|
+
updated = False
|
|
58
|
+
for flag in new_flags:
|
|
59
|
+
if flag not in flags:
|
|
60
|
+
flags.append(flag)
|
|
61
|
+
updated = True
|
|
62
|
+
|
|
63
|
+
if updated:
|
|
64
|
+
lines = []
|
|
65
|
+
for section, s_content in config.items():
|
|
66
|
+
lines.append(f"[{section}]")
|
|
67
|
+
for k, v in s_content.items():
|
|
68
|
+
if isinstance(v, list):
|
|
69
|
+
v_str = "[" + ", ".join(f'"{item}"' for item in v) + "]"
|
|
70
|
+
elif isinstance(v, str):
|
|
71
|
+
v_str = f'"{v}"'
|
|
72
|
+
else:
|
|
73
|
+
v_str = str(v)
|
|
74
|
+
lines.append(f"{k} = {v_str}")
|
|
75
|
+
lines.append("")
|
|
76
|
+
CONFIG_FILE.write_text("\n".join(lines))
|
|
77
|
+
logger.info("Automatically added missing build flags to existing configuration flags.")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.warning(f"Failed to migrate configuration file: {e}")
|
|
80
|
+
|
|
81
|
+
def load_config():
|
|
82
|
+
"""Load configuration from ~/.ndev/config.toml."""
|
|
83
|
+
init_layout()
|
|
84
|
+
try:
|
|
85
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
86
|
+
return tomllib.load(f)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.warning(f"Failed to load configuration: {e}. Using defaults.")
|
|
89
|
+
return tomllib.loads(DEFAULT_CONFIG)
|
|
90
|
+
|
|
91
|
+
def update_config(key_path: str, value):
|
|
92
|
+
"""Simple configuration updater. key_path is dot-separated (e.g. 'general.default_version')."""
|
|
93
|
+
config = load_config()
|
|
94
|
+
|
|
95
|
+
keys = key_path.split(".")
|
|
96
|
+
d = config
|
|
97
|
+
for k in keys[:-1]:
|
|
98
|
+
d = d.setdefault(k, {})
|
|
99
|
+
d[keys[-1]] = value
|
|
100
|
+
|
|
101
|
+
lines = []
|
|
102
|
+
for section, content in config.items():
|
|
103
|
+
lines.append(f"[{section}]")
|
|
104
|
+
for k, v in content.items():
|
|
105
|
+
if isinstance(v, list):
|
|
106
|
+
v_str = "[" + ", ".join(f'"{item}"' for item in v) + "]"
|
|
107
|
+
elif isinstance(v, str):
|
|
108
|
+
v_str = f'"{v}"'
|
|
109
|
+
else:
|
|
110
|
+
v_str = str(v)
|
|
111
|
+
lines.append(f"{k} = {v_str}")
|
|
112
|
+
lines.append("")
|
|
113
|
+
|
|
114
|
+
CONFIG_FILE.write_text("\n".join(lines))
|
ndev/common/constants.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
NDEV_DIR = Path(os.path.expanduser("~/.ndev")).resolve()
|
|
5
|
+
CACHE_DIR = NDEV_DIR / "cache"
|
|
6
|
+
DOWNLOADS_DIR = NDEV_DIR / "downloads"
|
|
7
|
+
BUILDS_DIR = NDEV_DIR / "builds"
|
|
8
|
+
CHROOT_DIR = NDEV_DIR / "chroot"
|
|
9
|
+
LOGS_DIR = NDEV_DIR / "logs"
|
|
10
|
+
RUN_DIR = NDEV_DIR / "run"
|
|
11
|
+
PHP_DIR = NDEV_DIR / "php"
|
|
12
|
+
CERTS_DIR = NDEV_DIR / "certs"
|
|
13
|
+
CURRENT_LINK = NDEV_DIR / "current"
|
|
14
|
+
CONFIG_FILE = NDEV_DIR / "config.toml"
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONFIG = """# ndev Configuration
|
|
17
|
+
|
|
18
|
+
[general]
|
|
19
|
+
default_version = ""
|
|
20
|
+
|
|
21
|
+
[build]
|
|
22
|
+
configure_flags = [
|
|
23
|
+
"--enable-fpm",
|
|
24
|
+
"--enable-mbstring",
|
|
25
|
+
"--enable-xml",
|
|
26
|
+
"--with-openssl",
|
|
27
|
+
"--with-zlib",
|
|
28
|
+
"--enable-pdo",
|
|
29
|
+
"--with-pdo-mysql",
|
|
30
|
+
"--with-mysqli",
|
|
31
|
+
"--with-curl",
|
|
32
|
+
"--enable-bcmath",
|
|
33
|
+
"--enable-calendar",
|
|
34
|
+
"--enable-exif",
|
|
35
|
+
"--enable-ftp",
|
|
36
|
+
"--enable-intl",
|
|
37
|
+
"--enable-pcntl",
|
|
38
|
+
"--enable-sockets",
|
|
39
|
+
"--enable-opcache",
|
|
40
|
+
"--enable-soap",
|
|
41
|
+
"--enable-gd",
|
|
42
|
+
"--with-jpeg",
|
|
43
|
+
"--with-webp",
|
|
44
|
+
"--with-freetype",
|
|
45
|
+
"--with-zip",
|
|
46
|
+
"--with-sodium",
|
|
47
|
+
"--with-bz2",
|
|
48
|
+
"--with-gmp",
|
|
49
|
+
"--with-readline"
|
|
50
|
+
]
|
|
51
|
+
"""
|
ndev/common/github.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from ndev.common.logger import logger
|
|
3
|
+
|
|
4
|
+
def fetch_releases(major_version: int) -> dict:
|
|
5
|
+
"""Fetch PHP releases metadata for a major version from php.net."""
|
|
6
|
+
url = f"https://www.php.net/releases/index.php?json=1&version={major_version}&max=100"
|
|
7
|
+
try:
|
|
8
|
+
response = httpx.get(url, timeout=10.0)
|
|
9
|
+
response.raise_for_status()
|
|
10
|
+
return response.json()
|
|
11
|
+
except Exception as e:
|
|
12
|
+
logger.warning(f"Failed to fetch PHP releases for major version {major_version}: {e}")
|
|
13
|
+
return {}
|
ndev/common/logger.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.logging import RichHandler
|
|
3
|
+
|
|
4
|
+
logging.basicConfig(
|
|
5
|
+
level=logging.INFO,
|
|
6
|
+
format="%(message)s",
|
|
7
|
+
datefmt="[%X]",
|
|
8
|
+
handlers=[RichHandler(rich_tracebacks=True, show_path=False, markup=True)]
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("ndev")
|
ndev/common/manifest.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ndev.common.constants import NDEV_DIR
|
|
5
|
+
from ndev.common.logger import logger
|
|
6
|
+
|
|
7
|
+
MANIFEST_FILE = NDEV_DIR / "manifest.json"
|
|
8
|
+
|
|
9
|
+
def load_manifest() -> dict:
|
|
10
|
+
"""Load the manifest of installed versions."""
|
|
11
|
+
if not MANIFEST_FILE.exists():
|
|
12
|
+
return {"installed": {}}
|
|
13
|
+
try:
|
|
14
|
+
return json.loads(MANIFEST_FILE.read_text())
|
|
15
|
+
except Exception as e:
|
|
16
|
+
logger.warning(f"Failed to read manifest: {e}. Returning empty.")
|
|
17
|
+
return {"installed": {}}
|
|
18
|
+
|
|
19
|
+
def save_manifest(manifest: dict):
|
|
20
|
+
"""Save the manifest of installed versions."""
|
|
21
|
+
try:
|
|
22
|
+
MANIFEST_FILE.write_text(json.dumps(manifest, indent=2))
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.error(f"Failed to write manifest: {e}")
|
|
25
|
+
|
|
26
|
+
def add_installed_version(version: str, path: str, configure_flags: list[str]):
|
|
27
|
+
"""Add a version to the installed manifest."""
|
|
28
|
+
manifest = load_manifest()
|
|
29
|
+
manifest["installed"][version] = {
|
|
30
|
+
"path": path,
|
|
31
|
+
"installed_at": datetime.datetime.now().isoformat(),
|
|
32
|
+
"configure_flags": configure_flags
|
|
33
|
+
}
|
|
34
|
+
save_manifest(manifest)
|
|
35
|
+
|
|
36
|
+
def remove_installed_version(version: str):
|
|
37
|
+
"""Remove a version from the installed manifest."""
|
|
38
|
+
manifest = load_manifest()
|
|
39
|
+
if version in manifest["installed"]:
|
|
40
|
+
del manifest["installed"][version]
|
|
41
|
+
save_manifest(manifest)
|
ndev/common/utils.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shlex
|
|
3
|
+
import sys
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from ndev.common.logger import logger
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
def run_command(cmd, cwd=None, env=None, check=True, capture_output=False, show_logs=True):
|
|
10
|
+
"""Run a shell command, printing output in real-time or capturing it."""
|
|
11
|
+
if isinstance(cmd, str):
|
|
12
|
+
cmd_args = shlex.split(cmd)
|
|
13
|
+
else:
|
|
14
|
+
cmd_args = cmd
|
|
15
|
+
|
|
16
|
+
logger.debug(f"Running command: {' '.join(shlex.quote(arg) for arg in cmd_args)} in cwd={cwd}")
|
|
17
|
+
|
|
18
|
+
if capture_output:
|
|
19
|
+
res = subprocess.run(cmd_args, cwd=cwd, env=env, capture_output=True, text=True)
|
|
20
|
+
if check and res.returncode != 0:
|
|
21
|
+
logger.error(f"Command failed with exit code {res.returncode}")
|
|
22
|
+
logger.error(f"Stdout: {res.stdout}")
|
|
23
|
+
logger.error(f"Stderr: {res.stderr}")
|
|
24
|
+
raise subprocess.CalledProcessError(res.returncode, cmd_args, res.stdout, res.stderr)
|
|
25
|
+
return res
|
|
26
|
+
|
|
27
|
+
p = subprocess.Popen(
|
|
28
|
+
cmd_args,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
env=env,
|
|
31
|
+
stdout=subprocess.PIPE,
|
|
32
|
+
stderr=subprocess.STDOUT,
|
|
33
|
+
text=True,
|
|
34
|
+
bufsize=1
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
output_lines = []
|
|
38
|
+
if p.stdout:
|
|
39
|
+
for line in p.stdout:
|
|
40
|
+
if show_logs:
|
|
41
|
+
sys.stdout.write(line)
|
|
42
|
+
sys.stdout.flush()
|
|
43
|
+
output_lines.append(line)
|
|
44
|
+
|
|
45
|
+
p.wait()
|
|
46
|
+
if check and p.returncode != 0:
|
|
47
|
+
output_str = "".join(output_lines)
|
|
48
|
+
if not show_logs:
|
|
49
|
+
logger.error(f"Command failed with exit code {p.returncode}")
|
|
50
|
+
logger.error("Command output:\n" + output_str)
|
|
51
|
+
raise subprocess.CalledProcessError(p.returncode, cmd_args, output_str)
|
|
52
|
+
return p.returncode
|
|
53
|
+
|
|
54
|
+
def get_version_or_prompt(version: str = None, prompt_message: str = "PHP version") -> str:
|
|
55
|
+
"""Return the provided version, the active version if set, or prompt the user."""
|
|
56
|
+
import typer
|
|
57
|
+
from ndev.common.constants import CURRENT_LINK, PHP_DIR
|
|
58
|
+
|
|
59
|
+
if version:
|
|
60
|
+
return version
|
|
61
|
+
|
|
62
|
+
if CURRENT_LINK.exists() and CURRENT_LINK.is_symlink():
|
|
63
|
+
return CURRENT_LINK.resolve().name
|
|
64
|
+
|
|
65
|
+
installed_versions = []
|
|
66
|
+
if PHP_DIR.exists():
|
|
67
|
+
for path in PHP_DIR.iterdir():
|
|
68
|
+
if path.is_dir():
|
|
69
|
+
installed_versions.append(path.name)
|
|
70
|
+
|
|
71
|
+
if installed_versions:
|
|
72
|
+
from packaging.version import parse as parse_version
|
|
73
|
+
try:
|
|
74
|
+
installed_versions = sorted(installed_versions, key=parse_version)
|
|
75
|
+
except Exception:
|
|
76
|
+
installed_versions = sorted(installed_versions)
|
|
77
|
+
|
|
78
|
+
console.print("\n[bold]Installed PHP Versions[/bold]")
|
|
79
|
+
console.print("----------------------")
|
|
80
|
+
for i, v in enumerate(installed_versions):
|
|
81
|
+
console.print(f" {i + 1}) {v}")
|
|
82
|
+
console.print("")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
choice = typer.prompt("Select PHP version index or enter version directly", default="1")
|
|
86
|
+
try:
|
|
87
|
+
idx = int(choice)
|
|
88
|
+
if 1 <= idx <= len(installed_versions):
|
|
89
|
+
return installed_versions[idx - 1]
|
|
90
|
+
except ValueError:
|
|
91
|
+
return choice.strip()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
return typer.prompt(prompt_message).strip()
|
|
96
|
+
|
ndev/linux/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Linux runtime engine and CLI for ndev."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ndev.common.constants import CHROOT_DIR, NDEV_DIR
|
|
5
|
+
from ndev.common.logger import logger
|
|
6
|
+
from ndev.common.utils import run_command
|
|
7
|
+
|
|
8
|
+
class SandboxManager:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.chroot_dir = CHROOT_DIR
|
|
11
|
+
self.ndev_dir = NDEV_DIR
|
|
12
|
+
|
|
13
|
+
def init_sandbox(self):
|
|
14
|
+
"""Prepare local directories in ~/.ndev/chroot for mounting."""
|
|
15
|
+
for sub_dir in ["bin", "sbin", "lib", "lib64", "usr", "etc", "proc", "dev", "sys", "tmp", "builds", "home"]:
|
|
16
|
+
(self.chroot_dir / sub_dir).mkdir(parents=True, exist_ok=True)
|
|
17
|
+
# Ensure usr/local exists
|
|
18
|
+
(self.chroot_dir / "usr" / "local").mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
def get_bwrap_command(self, cmd_args, cwd=None):
|
|
21
|
+
"""Build bubblewrap command that mounts the host system read-only."""
|
|
22
|
+
self.init_sandbox()
|
|
23
|
+
|
|
24
|
+
bwrap_cmd = [
|
|
25
|
+
"bwrap",
|
|
26
|
+
"--bind", str(self.chroot_dir), "/",
|
|
27
|
+
"--ro-bind-try", "/usr", "/usr",
|
|
28
|
+
"--bind", str(self.chroot_dir / "usr" / "local"), "/usr/local",
|
|
29
|
+
"--ro-bind-try", "/lib", "/lib",
|
|
30
|
+
"--ro-bind-try", "/lib64", "/lib64",
|
|
31
|
+
"--ro-bind-try", "/bin", "/bin",
|
|
32
|
+
"--ro-bind-try", "/sbin", "/sbin",
|
|
33
|
+
"--ro-bind-try", "/etc", "/etc",
|
|
34
|
+
"--proc", "/proc",
|
|
35
|
+
"--dev", "/dev",
|
|
36
|
+
"--tmpfs", "/tmp",
|
|
37
|
+
"--bind", str(self.ndev_dir), str(self.ndev_dir),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
home_dir = os.path.expanduser("~")
|
|
41
|
+
bwrap_cmd.extend(["--bind", home_dir, home_dir])
|
|
42
|
+
|
|
43
|
+
if cwd:
|
|
44
|
+
bwrap_cmd.extend(["--chdir", str(cwd)])
|
|
45
|
+
|
|
46
|
+
bwrap_cmd.append("--")
|
|
47
|
+
bwrap_cmd.extend(cmd_args)
|
|
48
|
+
|
|
49
|
+
return bwrap_cmd
|
|
50
|
+
|
|
51
|
+
def run(self, cmd_args, cwd=None, env=None, check=True, show_logs=True):
|
|
52
|
+
"""Run command inside the bubblewrap sandbox."""
|
|
53
|
+
if env is None:
|
|
54
|
+
env = os.environ.copy()
|
|
55
|
+
|
|
56
|
+
env["PKG_CONFIG_PATH"] = "/usr/local/lib/pkgconfig:/usr/local/lib/x86_64-linux-gnu/pkgconfig:" + env.get("PKG_CONFIG_PATH", "")
|
|
57
|
+
env["LD_LIBRARY_PATH"] = "/usr/local/lib:/usr/local/lib/x86_64-linux-gnu:" + env.get("LD_LIBRARY_PATH", "")
|
|
58
|
+
env["LIBRARY_PATH"] = "/usr/local/lib:/usr/local/lib/x86_64-linux-gnu:" + env.get("LIBRARY_PATH", "")
|
|
59
|
+
env["CPATH"] = "/usr/local/include:" + env.get("CPATH", "")
|
|
60
|
+
env["PATH"] = "/usr/local/bin:" + env.get("PATH", "")
|
|
61
|
+
|
|
62
|
+
bwrap_cmd = self.get_bwrap_command(cmd_args, cwd=cwd)
|
|
63
|
+
return run_command(bwrap_cmd, env=env, check=check, show_logs=show_logs)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ndev.linux.chroot.manager import SandboxManager
|
|
5
|
+
from ndev.common.logger import logger
|
|
6
|
+
from ndev.common.utils import run_command
|
|
7
|
+
|
|
8
|
+
def install_host_packages(packages: list[str], show_logs: bool = False):
|
|
9
|
+
"""Downloads, extracts, and moves package files to usr/local inside chroot."""
|
|
10
|
+
if not packages:
|
|
11
|
+
return
|
|
12
|
+
|
|
13
|
+
logger.info(f"Installing packages into sandbox: {', '.join(packages)}")
|
|
14
|
+
sandbox = SandboxManager()
|
|
15
|
+
sandbox.init_sandbox()
|
|
16
|
+
|
|
17
|
+
local_dir = sandbox.chroot_dir / "usr" / "local"
|
|
18
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
for sub in ["bin", "lib", "include", "share"]:
|
|
20
|
+
(local_dir / sub).mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
tmp_dir = sandbox.chroot_dir / "tmp" / "pkg_downloads"
|
|
23
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
cmd = ["apt-get", "download"] + packages
|
|
27
|
+
run_command(cmd, cwd=tmp_dir, show_logs=show_logs)
|
|
28
|
+
|
|
29
|
+
for deb in tmp_dir.glob("*.deb"):
|
|
30
|
+
extract_dest = tmp_dir / deb.stem
|
|
31
|
+
extract_dest.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
run_command(["dpkg", "-x", str(deb), str(extract_dest)], show_logs=show_logs)
|
|
33
|
+
|
|
34
|
+
usr_src = extract_dest / "usr"
|
|
35
|
+
if usr_src.exists():
|
|
36
|
+
for root, dirs, files in os.walk(usr_src):
|
|
37
|
+
rel_root = Path(root).relative_to(usr_src)
|
|
38
|
+
dest_root = local_dir / rel_root
|
|
39
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# Move directory symlinks
|
|
42
|
+
for d in list(dirs):
|
|
43
|
+
src_dir = Path(root) / d
|
|
44
|
+
if src_dir.is_symlink():
|
|
45
|
+
dest_dir = dest_root / d
|
|
46
|
+
if dest_dir.exists() or dest_dir.is_symlink():
|
|
47
|
+
if dest_dir.is_dir() and not dest_dir.is_symlink():
|
|
48
|
+
shutil.rmtree(dest_dir)
|
|
49
|
+
else:
|
|
50
|
+
dest_dir.unlink()
|
|
51
|
+
shutil.move(src_dir, dest_dir)
|
|
52
|
+
dirs.remove(d)
|
|
53
|
+
|
|
54
|
+
for file in files:
|
|
55
|
+
src_file = Path(root) / file
|
|
56
|
+
dest_file = dest_root / file
|
|
57
|
+
if dest_file.exists():
|
|
58
|
+
dest_file.unlink()
|
|
59
|
+
shutil.move(src_file, dest_file)
|
|
60
|
+
|
|
61
|
+
# Fix broken relative symlinks in usr/local/lib pointing to multiarch runtime libraries
|
|
62
|
+
lib_dir = local_dir / "lib"
|
|
63
|
+
if lib_dir.exists():
|
|
64
|
+
for root, dirs, files in os.walk(lib_dir):
|
|
65
|
+
for file in files:
|
|
66
|
+
file_path = Path(root) / file
|
|
67
|
+
if file_path.is_symlink():
|
|
68
|
+
target = os.readlink(file_path)
|
|
69
|
+
# Check if it is a broken link
|
|
70
|
+
if not file_path.exists():
|
|
71
|
+
# Find it on the host system
|
|
72
|
+
host_paths = [
|
|
73
|
+
Path("/usr/lib/x86_64-linux-gnu"),
|
|
74
|
+
Path("/lib/x86_64-linux-gnu"),
|
|
75
|
+
Path("/usr/lib"),
|
|
76
|
+
Path("/lib")
|
|
77
|
+
]
|
|
78
|
+
resolved = False
|
|
79
|
+
for hp in host_paths:
|
|
80
|
+
host_target = hp / target
|
|
81
|
+
if host_target.exists():
|
|
82
|
+
logger.info(f"Fixing broken symlink {file_path.name} -> {host_target}")
|
|
83
|
+
file_path.unlink()
|
|
84
|
+
file_path.symlink_to(host_target)
|
|
85
|
+
resolved = True
|
|
86
|
+
break
|
|
87
|
+
if not resolved:
|
|
88
|
+
logger.warning(f"Could not resolve symlink target '{target}' for {file_path}")
|
|
89
|
+
|
|
90
|
+
finally:
|
|
91
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from ndev.linux.chroot.manager import SandboxManager
|
|
3
|
+
|
|
4
|
+
def enter_sandbox_shell():
|
|
5
|
+
"""Launch interactive bash shell inside the bubblewrap sandbox."""
|
|
6
|
+
sandbox = SandboxManager()
|
|
7
|
+
cmd = ["bash"]
|
|
8
|
+
bwrap_cmd = sandbox.get_bwrap_command(cmd)
|
|
9
|
+
os.execvp(bwrap_cmd[0], bwrap_cmd)
|