ndev-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. ndev/__init__.py +8 -0
  2. ndev/__main__.py +4 -0
  3. ndev/cli.py +24 -0
  4. ndev/common/__init__.py +3 -0
  5. ndev/common/config.py +114 -0
  6. ndev/common/constants.py +51 -0
  7. ndev/common/github.py +13 -0
  8. ndev/common/logger.py +11 -0
  9. ndev/common/manifest.py +41 -0
  10. ndev/common/utils.py +96 -0
  11. ndev/linux/__init__.py +1 -0
  12. ndev/linux/chroot/manager.py +63 -0
  13. ndev/linux/chroot/packages.py +91 -0
  14. ndev/linux/chroot/shell.py +9 -0
  15. ndev/linux/cli.py +235 -0
  16. ndev/linux/commands/available.py +38 -0
  17. ndev/linux/commands/clean.py +25 -0
  18. ndev/linux/commands/ctl.py +192 -0
  19. ndev/linux/commands/current.py +11 -0
  20. ndev/linux/commands/db.py +319 -0
  21. ndev/linux/commands/doctor.py +56 -0
  22. ndev/linux/commands/grok.py +75 -0
  23. ndev/linux/commands/install.py +39 -0
  24. ndev/linux/commands/list.py +47 -0
  25. ndev/linux/commands/logs.py +36 -0
  26. ndev/linux/commands/mailpit.py +82 -0
  27. ndev/linux/commands/reload.py +26 -0
  28. ndev/linux/commands/restart.py +34 -0
  29. ndev/linux/commands/setup.py +113 -0
  30. ndev/linux/commands/start.py +34 -0
  31. ndev/linux/commands/status.py +81 -0
  32. ndev/linux/commands/stop.py +34 -0
  33. ndev/linux/commands/uninstall.py +69 -0
  34. ndev/linux/commands/update.py +57 -0
  35. ndev/linux/commands/upgrade.py +81 -0
  36. ndev/linux/commands/use.py +108 -0
  37. ndev/linux/commands/vhost.py +350 -0
  38. ndev/linux/php/builder.py +183 -0
  39. ndev/linux/php/downloader.py +58 -0
  40. ndev/linux/php/extensions.py +146 -0
  41. ndev/linux/php/installer.py +42 -0
  42. ndev/linux/php/resolver.py +59 -0
  43. ndev/linux/php/templates.py +128 -0
  44. ndev/linux/runtime/fpm.py +117 -0
  45. ndev/linux/runtime/mailpit.py +244 -0
  46. ndev/linux/runtime/pma.py +223 -0
  47. ndev/linux/runtime/process.py +37 -0
  48. ndev/linux/runtime/sockets.py +16 -0
  49. ndev/linux/runtime/upgrade.py +431 -0
  50. ndev/linux/tui.py +1423 -0
  51. ndev/main.py +52 -0
  52. ndev/tui.py +23 -0
  53. ndev/win/__init__.py +1 -0
  54. ndev/win/cli.py +1898 -0
  55. ndev/win/commands/__init__.py +0 -0
  56. ndev/win/core/__init__.py +0 -0
  57. ndev/win/core/db.py +265 -0
  58. ndev/win/core/elevate.py +94 -0
  59. ndev/win/core/ext.py +241 -0
  60. ndev/win/core/fcgi.py +216 -0
  61. ndev/win/core/grok.py +55 -0
  62. ndev/win/core/logs.py +66 -0
  63. ndev/win/core/mailpit.py +236 -0
  64. ndev/win/core/mkcert.py +65 -0
  65. ndev/win/core/paths.py +85 -0
  66. ndev/win/core/php.py +533 -0
  67. ndev/win/core/pma.py +190 -0
  68. ndev/win/core/services.py +349 -0
  69. ndev/win/core/setup.py +361 -0
  70. ndev/win/core/upgrade.py +513 -0
  71. ndev/win/core/vhost.py +289 -0
  72. ndev/win/templates/vhost.conf.tmpl +33 -0
  73. ndev/win/templates/vhost_ssl.conf.tmpl +43 -0
  74. ndev/win/tui.py +1313 -0
  75. ndev_stack-0.1.0.dist-info/METADATA +553 -0
  76. ndev_stack-0.1.0.dist-info/RECORD +79 -0
  77. ndev_stack-0.1.0.dist-info/WHEEL +5 -0
  78. ndev_stack-0.1.0.dist-info/entry_points.txt +4 -0
  79. ndev_stack-0.1.0.dist-info/top_level.txt +1 -0
ndev/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ ndev: Fast, cross-platform PHP developer environment for Windows and Linux.
3
+ """
4
+ __version__ = "0.1.0"
5
+
6
+ from . import common
7
+ from .common.logger import logger
8
+ from .common import constants
ndev/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ndev.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()
@@ -0,0 +1,3 @@
1
+ """Common cross-platform utilities, constants, and logging for ndev."""
2
+ from .logger import logger
3
+ from .constants import *
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))
@@ -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")
@@ -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)