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
@@ -0,0 +1,26 @@
1
+ import typer
2
+ from ndev.common.constants import CURRENT_LINK
3
+ from ndev.linux.runtime.fpm import reload_fpm
4
+ from ndev.common.logger import logger
5
+
6
+ def reload_cmd(target: str = typer.Argument(None, help="PHP version or service (e.g. 8.4, pma) to reload")):
7
+ """Gracefully reload PHP-FPM configuration for a version or restart pma service."""
8
+ if target and target.lower() in ["pma", "phpmyadmin"]:
9
+ from ndev.linux.runtime.pma import restart_pma
10
+ restart_pma()
11
+ return
12
+ from ndev.common.utils import get_version_or_prompt
13
+ try:
14
+ version = get_version_or_prompt(target, "PHP version or service to reload")
15
+ if version and version.lower() in ["pma", "phpmyadmin"]:
16
+ from ndev.linux.runtime.pma import restart_pma
17
+ restart_pma()
18
+ return
19
+ if not version:
20
+ logger.error("No version or service specified.")
21
+ raise typer.Exit(code=1)
22
+ reload_fpm(version)
23
+ except Exception as e:
24
+ logger.error(f"Failed to reload service: {e}")
25
+ raise typer.Exit(code=1)
26
+
@@ -0,0 +1,34 @@
1
+ import typer
2
+ from ndev.common.constants import CURRENT_LINK
3
+ from ndev.linux.runtime.fpm import restart_fpm
4
+ from ndev.common.logger import logger
5
+
6
+ def restart_cmd(target: str = typer.Argument(None, help="PHP version or service (e.g. 8.4, pma, mailpit) to restart")):
7
+ """Restart PHP-FPM for a version or a service like phpmyadmin (pma) or mailpit."""
8
+ if target and target.lower() in ["pma", "phpmyadmin"]:
9
+ from ndev.linux.runtime.pma import restart_pma
10
+ restart_pma()
11
+ return
12
+ if target and target.lower() in ["mailpit", "mail"]:
13
+ from ndev.linux.runtime.mailpit import restart_mailpit
14
+ restart_mailpit()
15
+ return
16
+ from ndev.common.utils import get_version_or_prompt
17
+ try:
18
+ version = get_version_or_prompt(target, "PHP version or service to restart")
19
+ if version and version.lower() in ["pma", "phpmyadmin"]:
20
+ from ndev.linux.runtime.pma import restart_pma
21
+ restart_pma()
22
+ return
23
+ if version and version.lower() in ["mailpit", "mail"]:
24
+ from ndev.linux.runtime.mailpit import restart_mailpit
25
+ restart_mailpit()
26
+ return
27
+ if not version:
28
+ logger.error("No version or service specified.")
29
+ raise typer.Exit(code=1)
30
+ restart_fpm(version)
31
+ except Exception as e:
32
+ logger.error(f"Failed to restart service: {e}")
33
+ raise typer.Exit(code=1)
34
+
@@ -0,0 +1,113 @@
1
+ import os
2
+ import sys
3
+ import shutil
4
+ import subprocess
5
+ import typer
6
+ import httpx
7
+ from pathlib import Path
8
+ from rich.console import Console
9
+ from ndev.common.constants import CURRENT_LINK
10
+ from ndev.common.logger import logger
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 get_user_local_bin_dir() -> Path:
25
+ sudo_user = os.environ.get("SUDO_USER")
26
+ if sudo_user:
27
+ try:
28
+ import pwd
29
+ return Path(pwd.getpwnam(sudo_user).pw_dir) / ".local" / "bin"
30
+ except Exception:
31
+ pass
32
+ return Path(os.path.expanduser("~/.local/bin"))
33
+
34
+ def setup_cmd():
35
+ """Install MariaDB, Nginx, and Composer on the system (elevates with sudo for system packages)."""
36
+ if not shutil.which("apt-get"):
37
+ logger.error("This setup command is only supported on Debian-based systems with apt.")
38
+ raise typer.Exit(code=1)
39
+
40
+ if not shutil.which("sudo"):
41
+ logger.error("sudo command not found. This command requires sudo to install system packages.")
42
+ raise typer.Exit(code=1)
43
+
44
+ try:
45
+ # 1. Update Package Lists
46
+ console.print("[bold yellow]Updating package lists (sudo apt-get update)...[/bold yellow]")
47
+ subprocess.run(["sudo", "apt-get", "update"], check=True)
48
+
49
+ # 2. Install MariaDB and Nginx
50
+ console.print("\n[bold yellow]Installing MariaDB and Nginx (sudo apt-get install)...[/bold yellow]")
51
+ subprocess.run(["sudo", "apt-get", "install", "-y", "mariadb-server", "nginx", "curl"], check=True)
52
+ console.print("[bold green]MariaDB and Nginx installed successfully![/bold green]\n")
53
+
54
+ # 3. Install Composer
55
+ local_bin = get_user_local_bin_dir()
56
+ composer_bin = local_bin / "composer"
57
+ if composer_bin.exists():
58
+ console.print(f"[bold green]Composer is already installed at {composer_bin}[/bold green]")
59
+ else:
60
+ # Find PHP binary
61
+ php_bin = None
62
+ if CURRENT_LINK.exists() or CURRENT_LINK.is_symlink():
63
+ potential_php = CURRENT_LINK / "bin" / "php"
64
+ if potential_php.exists():
65
+ php_bin = potential_php
66
+ if not php_bin:
67
+ system_php = shutil.which("php")
68
+ if system_php:
69
+ php_bin = Path(system_php)
70
+
71
+ if not php_bin:
72
+ console.print("[bold yellow]PHP is not installed on the system (neither active ndev PHP nor system php).[/bold yellow]")
73
+ console.print("[bold yellow]Skipping Composer installation. Please install a PHP version first using 'ndev install <version>' and run setup again.[/bold yellow]")
74
+ else:
75
+ console.print("[bold yellow]Installing Composer...[/bold yellow]")
76
+ installer_url = "https://getcomposer.org/installer"
77
+ res = httpx.get(installer_url, follow_redirects=True)
78
+ if res.status_code != 200:
79
+ raise RuntimeError(f"Failed to fetch Composer installer (HTTP status {res.status_code})")
80
+
81
+ setup_php = Path("/tmp/composer-setup.php")
82
+ setup_php.write_text(res.text)
83
+
84
+ local_bin.mkdir(parents=True, exist_ok=True)
85
+ chown_to_sudo_user(local_bin)
86
+
87
+ subprocess.run([str(php_bin), str(setup_php), f"--install-dir={local_bin}", "--filename=composer"], check=True)
88
+ if setup_php.exists():
89
+ setup_php.unlink()
90
+
91
+ chown_to_sudo_user(composer_bin)
92
+ console.print(f"[bold green]Composer installed successfully under {composer_bin}[/bold green]")
93
+
94
+ # 4. Create ndev symlink in ~/.local/bin/ndev pointing to the virtualenv ndev executable
95
+ ndev_venv_path = Path(sys.executable).parent / "ndev"
96
+ if ndev_venv_path.exists():
97
+ ndev_link = local_bin / "ndev"
98
+ if ndev_link.exists() or ndev_link.is_symlink():
99
+ ndev_link.unlink()
100
+ local_bin.mkdir(parents=True, exist_ok=True)
101
+ chown_to_sudo_user(local_bin)
102
+ ndev_link.symlink_to(ndev_venv_path)
103
+ chown_to_sudo_user(ndev_link)
104
+ console.print(f"[bold green]Created symlink {ndev_link} -> {ndev_venv_path}[/bold green]")
105
+
106
+ console.print("\n[bold green]System setup completed successfully![/bold green]")
107
+
108
+ except subprocess.CalledProcessError as e:
109
+ logger.error(f"Command execution failed: {e}")
110
+ raise typer.Exit(code=1)
111
+ except Exception as e:
112
+ logger.error(f"Setup failed: {e}")
113
+ raise typer.Exit(code=1)
@@ -0,0 +1,34 @@
1
+ import typer
2
+ from ndev.common.constants import CURRENT_LINK
3
+ from ndev.linux.runtime.fpm import start_fpm
4
+ from ndev.common.logger import logger
5
+
6
+ def start_cmd(target: str = typer.Argument(None, help="PHP version or service (e.g. 8.4, pma, mailpit) to start")):
7
+ """Start PHP-FPM for a version or a service like phpmyadmin (pma) or mailpit."""
8
+ if target and target.lower() in ["pma", "phpmyadmin"]:
9
+ from ndev.linux.runtime.pma import start_pma
10
+ start_pma()
11
+ return
12
+ if target and target.lower() in ["mailpit", "mail"]:
13
+ from ndev.linux.runtime.mailpit import start_mailpit
14
+ start_mailpit()
15
+ return
16
+ from ndev.common.utils import get_version_or_prompt
17
+ try:
18
+ version = get_version_or_prompt(target, "PHP version or service to start")
19
+ if version and version.lower() in ["pma", "phpmyadmin"]:
20
+ from ndev.linux.runtime.pma import start_pma
21
+ start_pma()
22
+ return
23
+ if version and version.lower() in ["mailpit", "mail"]:
24
+ from ndev.linux.runtime.mailpit import start_mailpit
25
+ start_mailpit()
26
+ return
27
+ if not version:
28
+ logger.error("No version or service specified.")
29
+ raise typer.Exit(code=1)
30
+ start_fpm(version)
31
+ except Exception as e:
32
+ logger.error(f"Failed to start service: {e}")
33
+ raise typer.Exit(code=1)
34
+
@@ -0,0 +1,81 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from ndev.common.constants import CURRENT_LINK
5
+ from ndev.linux.runtime.fpm import get_fpm_status
6
+ from ndev.common.logger import logger
7
+
8
+ console = Console()
9
+
10
+ def status_cmd(target: str = typer.Argument(None, help="PHP version or service (e.g. 8.4, pma, mailpit) to check status for")):
11
+ """Check status of a PHP-FPM version or service like pma or mailpit."""
12
+ if target and target.lower() in ["pma", "phpmyadmin"]:
13
+ from ndev.linux.runtime.pma import get_pma_status
14
+ status = get_pma_status()
15
+ table = Table(title="phpMyAdmin Service Status")
16
+ table.add_column("Property", style="bold cyan")
17
+ table.add_column("Value")
18
+
19
+ status_text = "[bold green]Running[/bold green]" if status["running"] else "[bold red]Stopped[/bold red]"
20
+ table.add_row("Service", "phpMyAdmin (pma)")
21
+ table.add_row("Status", status_text)
22
+ table.add_row("PID", str(status["pid"]) if status["pid"] else "N/A")
23
+ table.add_row("Port", str(status["port"]) if status["port"] else "N/A")
24
+ table.add_row("URL", status["url"] if status["url"] else "N/A")
25
+ console.print(table)
26
+ return
27
+
28
+ if target and target.lower() in ["mailpit", "mail"]:
29
+ from ndev.linux.runtime.mailpit import get_mailpit_status
30
+ status = get_mailpit_status()
31
+ table = Table(title="Mailpit Service Status")
32
+ table.add_column("Property", style="bold cyan")
33
+ table.add_column("Value")
34
+
35
+ status_text = "[bold green]Running[/bold green]" if status["running"] else "[bold red]Stopped[/bold red]"
36
+ table.add_row("Service", "Mailpit")
37
+ table.add_row("Status", status_text)
38
+ table.add_row("PID", str(status["pid"]) if status["pid"] else "N/A")
39
+ table.add_row("SMTP Server", f"127.0.0.1:{status['smtp_port']}")
40
+ table.add_row("Web UI URL", status["url"] if status["url"] else f"http://127.0.0.1:{status['web_port']} (Stopped)")
41
+ console.print(table)
42
+ return
43
+
44
+ from ndev.common.utils import get_version_or_prompt
45
+ try:
46
+ version = get_version_or_prompt(target, "PHP version or service to check status")
47
+ if version and version.lower() in ["pma", "phpmyadmin"]:
48
+ from ndev.linux.runtime.pma import get_pma_status
49
+ status = get_pma_status()
50
+ table = Table(title="phpMyAdmin Service Status")
51
+ table.add_column("Property", style="bold cyan")
52
+ table.add_column("Value")
53
+ status_text = "[bold green]Running[/bold green]" if status["running"] else "[bold red]Stopped[/bold red]"
54
+ table.add_row("Service", "phpMyAdmin (pma)")
55
+ table.add_row("Status", status_text)
56
+ table.add_row("PID", str(status["pid"]) if status["pid"] else "N/A")
57
+ table.add_row("Port", str(status["port"]) if status["port"] else "N/A")
58
+ table.add_row("URL", status["url"] if status["url"] else "N/A")
59
+ console.print(table)
60
+ return
61
+
62
+ if not version:
63
+ logger.error("No version or service specified.")
64
+ raise typer.Exit(code=1)
65
+ status = get_fpm_status(version)
66
+
67
+ table = Table(title=f"PHP-FPM {version} Status")
68
+ table.add_column("Property", style="bold cyan")
69
+ table.add_column("Value")
70
+
71
+ status_text = "[bold green]Running[/bold green]" if status["running"] else "[bold red]Stopped[/bold red]"
72
+ table.add_row("Version", status["version"])
73
+ table.add_row("Status", status_text)
74
+ table.add_row("PID", str(status["pid"]) if status["pid"] else "N/A")
75
+ table.add_row("Socket Path", status["socket"])
76
+ table.add_row("Socket Active", "[green]Yes[/green]" if status["socket_exists"] else "[yellow]No[/yellow]")
77
+
78
+ console.print(table)
79
+ except Exception as e:
80
+ logger.error(f"Failed to get service status: {e}")
81
+ raise typer.Exit(code=1)
@@ -0,0 +1,34 @@
1
+ import typer
2
+ from ndev.common.constants import CURRENT_LINK
3
+ from ndev.linux.runtime.fpm import stop_fpm
4
+ from ndev.common.logger import logger
5
+
6
+ def stop_cmd(target: str = typer.Argument(None, help="PHP version or service (e.g. 8.4, pma, mailpit) to stop")):
7
+ """Stop PHP-FPM for a version or a service like phpmyadmin (pma) or mailpit."""
8
+ if target and target.lower() in ["pma", "phpmyadmin"]:
9
+ from ndev.linux.runtime.pma import stop_pma
10
+ stop_pma()
11
+ return
12
+ if target and target.lower() in ["mailpit", "mail"]:
13
+ from ndev.linux.runtime.mailpit import stop_mailpit
14
+ stop_mailpit()
15
+ return
16
+ from ndev.common.utils import get_version_or_prompt
17
+ try:
18
+ version = get_version_or_prompt(target, "PHP version or service to stop")
19
+ if version and version.lower() in ["pma", "phpmyadmin"]:
20
+ from ndev.linux.runtime.pma import stop_pma
21
+ stop_pma()
22
+ return
23
+ if version and version.lower() in ["mailpit", "mail"]:
24
+ from ndev.linux.runtime.mailpit import stop_mailpit
25
+ stop_mailpit()
26
+ return
27
+ if not version:
28
+ logger.error("No version or service specified.")
29
+ raise typer.Exit(code=1)
30
+ stop_fpm(version)
31
+ except Exception as e:
32
+ logger.error(f"Failed to stop service: {e}")
33
+ raise typer.Exit(code=1)
34
+
@@ -0,0 +1,69 @@
1
+ import shutil
2
+ import typer
3
+ from ndev.common.constants import PHP_DIR, CURRENT_LINK
4
+ from ndev.linux.runtime.fpm import stop_fpm
5
+ from ndev.common.manifest import remove_installed_version
6
+ from ndev.common.logger import logger
7
+
8
+ def uninstall_cmd(version: str = typer.Argument(None, help="PHP version to uninstall (e.g. 8.4.23)")):
9
+ """Uninstall a compiled PHP version."""
10
+ if not version:
11
+ # Check if there are installed versions to list
12
+ installed_versions = []
13
+ if PHP_DIR.exists():
14
+ for path in PHP_DIR.iterdir():
15
+ if path.is_dir():
16
+ installed_versions.append(path.name)
17
+ if installed_versions:
18
+ from packaging.version import parse as parse_version
19
+ try:
20
+ installed_versions = sorted(installed_versions, key=parse_version)
21
+ except Exception:
22
+ installed_versions = sorted(installed_versions)
23
+ from rich.console import Console
24
+ console = Console()
25
+ console.print("\n[bold]Installed PHP Versions[/bold]")
26
+ console.print("----------------------")
27
+ for i, v in enumerate(installed_versions):
28
+ console.print(f" {i + 1}) {v}")
29
+ console.print("")
30
+ try:
31
+ choice = typer.prompt("Select PHP version index or enter version directly", default="1")
32
+ try:
33
+ idx = int(choice)
34
+ if 1 <= idx <= len(installed_versions):
35
+ version = installed_versions[idx - 1]
36
+ except ValueError:
37
+ version = choice.strip()
38
+ except Exception:
39
+ pass
40
+ if not version:
41
+ version = typer.prompt("PHP version to uninstall").strip()
42
+
43
+ if not version:
44
+ logger.error("PHP version is required.")
45
+ raise typer.Exit(code=1)
46
+
47
+ prefix = PHP_DIR / version
48
+
49
+ if not prefix.exists():
50
+ logger.error(f"PHP version {version} is not installed.")
51
+ raise typer.Exit(code=1)
52
+
53
+ try:
54
+ stop_fpm(version)
55
+
56
+ logger.info(f"Removing files at {prefix}...")
57
+ shutil.rmtree(prefix)
58
+
59
+ remove_installed_version(version)
60
+
61
+ if CURRENT_LINK.exists() and CURRENT_LINK.is_symlink():
62
+ if CURRENT_LINK.resolve() == prefix.resolve():
63
+ logger.info("Removing symlink to current version.")
64
+ CURRENT_LINK.unlink()
65
+
66
+ logger.info(f"PHP version {version} uninstalled successfully.")
67
+ except Exception as e:
68
+ logger.error(f"Uninstallation failed: {e}")
69
+ raise typer.Exit(code=1)
@@ -0,0 +1,57 @@
1
+ import typer
2
+ from packaging.version import parse as parse_version
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from ndev.common.constants import PHP_DIR
6
+ from ndev.common.github import fetch_releases
7
+ from ndev.common.logger import logger
8
+
9
+ console = Console()
10
+
11
+ def update_cmd():
12
+ """Check if installed PHP versions have newer patch releases available."""
13
+ if not PHP_DIR.exists():
14
+ logger.info("No PHP versions installed yet.")
15
+ return
16
+
17
+ installed_versions = []
18
+ for path in PHP_DIR.iterdir():
19
+ if path.is_dir():
20
+ installed_versions.append(path.name)
21
+
22
+ if not installed_versions:
23
+ logger.info("No PHP versions installed yet.")
24
+ return
25
+
26
+ logger.info("Checking for newer patch releases...")
27
+
28
+ table = Table(title="PHP Update Check")
29
+ table.add_column("Installed Version", style="bold cyan")
30
+ table.add_column("Latest Available")
31
+ table.add_column("Status")
32
+
33
+ for v in installed_versions:
34
+ parts = v.split(".")
35
+ try:
36
+ major = int(parts[0])
37
+ minor_prefix = f"{parts[0]}.{parts[1]}."
38
+ except (ValueError, IndexError):
39
+ continue
40
+
41
+ releases = fetch_releases(major)
42
+ if not releases:
43
+ table.add_row(v, "Unknown", "[yellow]Network Error[/yellow]")
44
+ continue
45
+
46
+ matching = [rel for rel in releases.keys() if rel.startswith(minor_prefix)]
47
+ if not matching:
48
+ table.add_row(v, v, "Up to date")
49
+ continue
50
+
51
+ latest = max(matching, key=parse_version)
52
+ if parse_version(latest) > parse_version(v):
53
+ table.add_row(v, latest, f"[bold yellow]Update Available[/bold yellow] (run 'ndev install {latest}')")
54
+ else:
55
+ table.add_row(v, v, "Up to date")
56
+
57
+ console.print(table)
@@ -0,0 +1,81 @@
1
+ """
2
+ `ndev upgrade` command for Linux stack components.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ndev.linux.runtime import upgrade as upgrade_rt
12
+
13
+ app = typer.Typer(help="Check for and upgrade stack components (Nginx, Mailpit, MariaDB, PMA, mkcert, Composer).")
14
+ console = Console()
15
+
16
+
17
+ @app.callback(invoke_without_command=True)
18
+ def main(
19
+ ctx: typer.Context,
20
+ component: Optional[str] = typer.Argument(None, help="Component to upgrade (nginx, mailpit, mariadb, pma, mkcert, composer, or all)"),
21
+ check: bool = typer.Option(False, "--check", "-c", help="Only check for updates without applying upgrades."),
22
+ ):
23
+ if ctx.invoked_subcommand is not None:
24
+ return
25
+
26
+ console.print("\n[bold blue]ndev Linux Stack Component Updates & Upgrades[/bold blue]")
27
+
28
+ with console.status("[bold green]Checking component versions...[/bold green]"):
29
+ if component and component.lower() != "all":
30
+ infos = [info for info in upgrade_rt.check_all() if info.name == component.lower() or component.lower() in info.name]
31
+ if not infos:
32
+ console.print(f"[bold red]Unknown component '{component}'. Available: {', '.join(upgrade_rt.COMPONENTS)}[/bold red]")
33
+ raise typer.Exit(1)
34
+ else:
35
+ infos = upgrade_rt.check_all()
36
+
37
+ table = Table(title="Stack Components Version Status", show_header=True, header_style="bold cyan")
38
+ table.add_column("Component", style="bold", min_width=20)
39
+ table.add_column("Installed Version", min_width=18)
40
+ table.add_column("Latest Version", min_width=18)
41
+ table.add_column("Status", min_width=22)
42
+
43
+ upgradable = []
44
+ for info in infos:
45
+ curr_str = info.current_version or "[dim]Not installed[/dim]"
46
+ latest_str = info.latest_version or "[dim]Unknown[/dim]"
47
+ if not info.installed:
48
+ st_str = "[yellow]Not Installed[/yellow]"
49
+ elif info.update_available:
50
+ st_str = "[bold green]Update Available[/bold green]"
51
+ upgradable.append(info)
52
+ else:
53
+ st_str = "[green]Up-to-date[/green]"
54
+
55
+ table.add_row(info.display_name, curr_str, latest_str, st_str)
56
+
57
+ console.print(table)
58
+
59
+ if check:
60
+ if upgradable:
61
+ console.print(f"\n[bold green]{len(upgradable)} component(s) can be upgraded.[/bold green] Run `ndev upgrade` to apply.")
62
+ else:
63
+ console.print("\n[bold green]All installed components are up-to-date![/bold green]")
64
+ return
65
+
66
+ if not upgradable and not component:
67
+ console.print("\n[bold green]All installed components are up-to-date![/bold green]")
68
+ return
69
+
70
+ targets = [c.name for c in upgradable] if not component or component.lower() == "all" else [component.lower()]
71
+ console.print(f"\n[bold blue]Upgrading {len(targets)} component(s): {', '.join(targets)}...[/bold blue]\n")
72
+
73
+ for target in targets:
74
+ with console.status(f"[bold green]Upgrading {target}...[/bold green]"):
75
+ ok, msg = upgrade_rt.upgrade_component(target)
76
+ if ok:
77
+ console.print(f"[bold green]✓ {msg}[/bold green]")
78
+ else:
79
+ console.print(f"[bold red]✗ {msg}[/bold red]")
80
+
81
+ console.print("\n[bold green]Upgrade process complete![/bold green]")
@@ -0,0 +1,108 @@
1
+ import typer
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+ from ndev.common.constants import PHP_DIR, CURRENT_LINK
6
+ from ndev.common.logger import logger
7
+
8
+ def use_cmd(version: str = typer.Argument(None, help="PHP version to use (must be installed)")):
9
+ """Set a PHP version as the active version."""
10
+ if not version:
11
+ # Check if there are installed versions to list
12
+ installed_versions = []
13
+ if PHP_DIR.exists():
14
+ for path in PHP_DIR.iterdir():
15
+ if path.is_dir():
16
+ installed_versions.append(path.name)
17
+ if installed_versions:
18
+ from packaging.version import parse as parse_version
19
+ try:
20
+ installed_versions = sorted(installed_versions, key=parse_version)
21
+ except Exception:
22
+ installed_versions = sorted(installed_versions)
23
+ from rich.console import Console
24
+ console = Console()
25
+ console.print("\n[bold]Installed PHP Versions[/bold]")
26
+ console.print("----------------------")
27
+ for i, v in enumerate(installed_versions):
28
+ console.print(f" {i + 1}) {v}")
29
+ console.print("")
30
+ try:
31
+ choice = typer.prompt("Select PHP version index or enter version directly", default="1")
32
+ try:
33
+ idx = int(choice)
34
+ if 1 <= idx <= len(installed_versions):
35
+ version = installed_versions[idx - 1]
36
+ except ValueError:
37
+ version = choice.strip()
38
+ except Exception:
39
+ pass
40
+ if not version:
41
+ version = typer.prompt("PHP version to use").strip()
42
+
43
+ if not version:
44
+ logger.error("PHP version is required.")
45
+ raise typer.Exit(code=1)
46
+
47
+ target = PHP_DIR / version
48
+
49
+ if not target.exists():
50
+ logger.error(f"PHP version {version} is not installed. Install it first using 'ndev install {version}'.")
51
+ raise typer.Exit(code=1)
52
+
53
+ try:
54
+ if CURRENT_LINK.exists() or CURRENT_LINK.is_symlink():
55
+ CURRENT_LINK.unlink()
56
+ CURRENT_LINK.symlink_to(target)
57
+
58
+ # Ensure ~/.local/bin symlinks exist
59
+ local_bin = Path(os.path.expanduser("~/.local/bin"))
60
+ local_bin.mkdir(parents=True, exist_ok=True)
61
+
62
+ links = {
63
+ "php": CURRENT_LINK / "bin" / "php",
64
+ "phpize": CURRENT_LINK / "bin" / "phpize",
65
+ "php-config": CURRENT_LINK / "bin" / "php-config",
66
+ "php-fpm": CURRENT_LINK / "sbin" / "php-fpm"
67
+ }
68
+
69
+ for name, target_path in links.items():
70
+ link_path = local_bin / name
71
+ if link_path.exists() or link_path.is_symlink():
72
+ link_path.unlink()
73
+ link_path.symlink_to(target_path)
74
+
75
+ logger.info(f"Now using PHP version {version}")
76
+
77
+ # Check if ~/.local/bin is in PATH
78
+ path_env = os.environ.get("PATH", "")
79
+ local_bin_str = str(local_bin)
80
+ in_path = any(
81
+ p == local_bin_str or Path(p).resolve() == local_bin.resolve()
82
+ for p in path_env.split(os.pathsep)
83
+ )
84
+ if not in_path:
85
+ logger.warning(
86
+ f"[yellow]Warning: {local_bin} is not in your PATH. [/yellow]"
87
+ f"You may need to add it to your shell configuration (e.g. ~/.bashrc or ~/.zshrc):\n"
88
+ f' export PATH="$HOME/.local/bin:$PATH"'
89
+ )
90
+ else:
91
+ # Verify if the active php command resolves to the ndev symlink
92
+ php_path = shutil.which("php")
93
+ if php_path:
94
+ try:
95
+ resolved_php = Path(php_path).resolve()
96
+ expected_php = (local_bin / "php").resolve()
97
+ if resolved_php != expected_php:
98
+ logger.warning(
99
+ f"[yellow]Warning: The active PHP command resolves to '{resolved_php}' [/yellow]\n"
100
+ f"which is not the ndev symlink '{expected_php}'.\n"
101
+ f"Please ensure '{local_bin}' appears before other PHP installations in your PATH."
102
+ )
103
+ except Exception:
104
+ pass
105
+
106
+ except Exception as e:
107
+ logger.error(f"Failed to switch PHP version: {e}")
108
+ raise typer.Exit(code=1)