ndev-stack 0.3.9__tar.gz → 0.3.10__tar.gz

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 (106) hide show
  1. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/PKG-INFO +1 -1
  2. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/__init__.py +1 -1
  3. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/nginx_helper.py +70 -21
  4. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/tui.py +126 -24
  5. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev_stack.egg-info/PKG-INFO +1 -1
  6. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/pyproject.toml +1 -1
  7. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/tests/test_opensuse_compat.py +22 -0
  8. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/README.md +0 -0
  9. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/__main__.py +0 -0
  10. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/cli.py +0 -0
  11. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/__init__.py +0 -0
  12. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/config.py +0 -0
  13. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/constants.py +0 -0
  14. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/distro.py +0 -0
  15. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/github.py +0 -0
  16. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/logger.py +0 -0
  17. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/manifest.py +0 -0
  18. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/__init__.py +0 -0
  19. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/base.py +0 -0
  20. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/mailpit/manifest.json +0 -0
  21. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/mailpit/module.py +0 -0
  22. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/mongodb/manifest.json +0 -0
  23. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/mongodb/module.py +0 -0
  24. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/postgres/manifest.json +0 -0
  25. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/postgres/module.py +0 -0
  26. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/redis/manifest.json +0 -0
  27. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/builtin/redis/module.py +0 -0
  28. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/modules/manager.py +0 -0
  29. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/common/utils.py +0 -0
  30. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/__init__.py +0 -0
  31. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/chroot/manager.py +0 -0
  32. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/chroot/packages.py +0 -0
  33. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/chroot/shell.py +0 -0
  34. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/cli.py +0 -0
  35. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/available.py +0 -0
  36. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/clean.py +0 -0
  37. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/ctl.py +0 -0
  38. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/current.py +0 -0
  39. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/db.py +0 -0
  40. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/doctor.py +0 -0
  41. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/grok.py +0 -0
  42. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/install.py +0 -0
  43. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/list.py +0 -0
  44. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/logs.py +0 -0
  45. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/mailpit.py +0 -0
  46. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/modules.py +0 -0
  47. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/mongodb.py +0 -0
  48. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/postgres.py +0 -0
  49. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/reload.py +0 -0
  50. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/restart.py +0 -0
  51. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/setup.py +0 -0
  52. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/start.py +0 -0
  53. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/status.py +0 -0
  54. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/stop.py +0 -0
  55. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/uninstall.py +0 -0
  56. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/update.py +0 -0
  57. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/upgrade.py +0 -0
  58. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/use.py +0 -0
  59. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/commands/vhost.py +0 -0
  60. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/php/builder.py +0 -0
  61. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/php/downloader.py +0 -0
  62. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/php/extensions.py +0 -0
  63. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/php/installer.py +0 -0
  64. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/php/resolver.py +0 -0
  65. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/php/templates.py +0 -0
  66. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/fpm.py +0 -0
  67. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/mailpit.py +0 -0
  68. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/pma.py +0 -0
  69. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/process.py +0 -0
  70. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/services.py +0 -0
  71. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/sockets.py +0 -0
  72. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/linux/runtime/upgrade.py +0 -0
  73. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/main.py +0 -0
  74. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/tui.py +0 -0
  75. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/__init__.py +0 -0
  76. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/bin/shim.exe +0 -0
  77. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/cli.py +0 -0
  78. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/commands/__init__.py +0 -0
  79. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/__init__.py +0 -0
  80. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/db.py +0 -0
  81. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/elevate.py +0 -0
  82. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/ext.py +0 -0
  83. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/fcgi.py +0 -0
  84. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/grok.py +0 -0
  85. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/logs.py +0 -0
  86. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/mailpit.py +0 -0
  87. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/mkcert.py +0 -0
  88. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/paths.py +0 -0
  89. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/php.py +0 -0
  90. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/pma.py +0 -0
  91. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/redis_core.py +0 -0
  92. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/services.py +0 -0
  93. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/setup.py +0 -0
  94. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/shim.py +0 -0
  95. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/upgrade.py +0 -0
  96. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/core/vhost.py +0 -0
  97. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/templates/vhost.conf.tmpl +0 -0
  98. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/templates/vhost_ssl.conf.tmpl +0 -0
  99. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev/win/tui.py +0 -0
  100. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev_stack.egg-info/SOURCES.txt +0 -0
  101. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev_stack.egg-info/dependency_links.txt +0 -0
  102. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev_stack.egg-info/entry_points.txt +0 -0
  103. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev_stack.egg-info/requires.txt +0 -0
  104. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/ndev_stack.egg-info/top_level.txt +0 -0
  105. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/setup.cfg +0 -0
  106. {ndev_stack-0.3.9 → ndev_stack-0.3.10}/tests/test_shim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndev-stack
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows
5
5
  Author-email: Ankit Karan <ankit.karan99@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankitkaran99/ndev
@@ -1,7 +1,7 @@
1
1
  """
2
2
  ndev: Fast, cross-platform PHP developer environment for Windows and Linux.
3
3
  """
4
- __version__ = "0.3.9"
4
+ __version__ = "0.3.10"
5
5
 
6
6
  from . import common as common
7
7
  from .common.logger import logger as logger
@@ -32,7 +32,41 @@ def is_ipv6_supported() -> bool:
32
32
  return False
33
33
 
34
34
 
35
- def write_privileged_file(target: Path, content: str) -> None:
35
+ def validate_sudo_password(password: str) -> bool:
36
+ """
37
+ Validate sudo password using 'sudo -v -S' and refresh sudo credential timestamp.
38
+ Returns True if password is valid, False otherwise.
39
+ """
40
+ if not password:
41
+ return False
42
+ try:
43
+ proc = subprocess.run(
44
+ ["sudo", "-v", "-S"],
45
+ input=f"{password}\n",
46
+ text=True,
47
+ capture_output=True,
48
+ timeout=5
49
+ )
50
+ return proc.returncode == 0
51
+ except Exception:
52
+ return False
53
+
54
+
55
+ def check_sudo_needs_password() -> bool:
56
+ """
57
+ Check if sudo commands currently require a password prompt.
58
+ Returns True if sudo requires password, False if root or timestamp is active.
59
+ """
60
+ if os.geteuid() == 0:
61
+ return False
62
+ try:
63
+ res = subprocess.run(["sudo", "-n", "true"], capture_output=True, timeout=3)
64
+ return res.returncode != 0
65
+ except Exception:
66
+ return True
67
+
68
+
69
+ def write_privileged_file(target: Path, content: str, password: Optional[str] = None) -> None:
36
70
  """
37
71
  Write content to a file, using sudo tee if current process lacks permissions.
38
72
  Ensures parent directories exist.
@@ -50,10 +84,14 @@ def write_privileged_file(target: Path, content: str) -> None:
50
84
  except (PermissionError, OSError):
51
85
  pass
52
86
 
53
- # Fallback to sudo
54
- subprocess.run(["sudo", "mkdir", "-p", str(target.parent)], check=True)
87
+ # If password supplied, ensure sudo credential timestamp is active
88
+ if password:
89
+ validate_sudo_password(password)
90
+
91
+ # Fallback to non-interactive sudo
92
+ subprocess.run(["sudo", "-n", "mkdir", "-p", str(target.parent)], check=True)
55
93
  subprocess.run(
56
- ["sudo", "tee", str(target)],
94
+ ["sudo", "-n", "tee", str(target)],
57
95
  input=content,
58
96
  text=True,
59
97
  capture_output=True,
@@ -61,7 +99,7 @@ def write_privileged_file(target: Path, content: str) -> None:
61
99
  )
62
100
 
63
101
 
64
- def symlink_privileged(target: Path, link: Path) -> None:
102
+ def symlink_privileged(target: Path, link: Path, password: Optional[str] = None) -> None:
65
103
  """
66
104
  Create a symbolic link, using sudo if current process lacks permissions.
67
105
  Ensures parent directory exists and removes any existing destination link/file.
@@ -81,11 +119,14 @@ def symlink_privileged(target: Path, link: Path) -> None:
81
119
  except (PermissionError, OSError):
82
120
  pass
83
121
 
84
- subprocess.run(["sudo", "mkdir", "-p", str(link.parent)], check=True)
85
- subprocess.run(["sudo", "ln", "-sf", str(target_resolved), str(link)], check=True)
122
+ if password:
123
+ validate_sudo_password(password)
86
124
 
125
+ subprocess.run(["sudo", "-n", "mkdir", "-p", str(link.parent)], check=True)
126
+ subprocess.run(["sudo", "-n", "ln", "-sf", str(target_resolved), str(link)], check=True)
87
127
 
88
- def unlink_privileged(path: Path) -> None:
128
+
129
+ def unlink_privileged(path: Path, password: Optional[str] = None) -> None:
89
130
  """
90
131
  Unlink a file or symlink, falling back to sudo if needed.
91
132
  """
@@ -102,10 +143,13 @@ def unlink_privileged(path: Path) -> None:
102
143
  except (PermissionError, OSError):
103
144
  pass
104
145
 
105
- subprocess.run(["sudo", "rm", "-f", str(path)], check=True)
146
+ if password:
147
+ validate_sudo_password(password)
148
+
149
+ subprocess.run(["sudo", "-n", "rm", "-f", str(path)], check=True)
106
150
 
107
151
 
108
- def add_to_hosts_privileged(domain: str, ip: str = "127.0.0.1") -> None:
152
+ def add_to_hosts_privileged(domain: str, ip: str = "127.0.0.1", password: Optional[str] = None) -> None:
109
153
  """
110
154
  Safely append a domain mapping to /etc/hosts if not already present.
111
155
  """
@@ -139,9 +183,12 @@ def add_to_hosts_privileged(domain: str, ip: str = "127.0.0.1") -> None:
139
183
  except (PermissionError, OSError):
140
184
  pass
141
185
 
186
+ if password:
187
+ validate_sudo_password(password)
188
+
142
189
  try:
143
190
  subprocess.run(
144
- ["sudo", "tee", "-a", str(hosts_file)],
191
+ ["sudo", "-n", "tee", "-a", str(hosts_file)],
145
192
  input=line_to_add,
146
193
  text=True,
147
194
  capture_output=True,
@@ -151,7 +198,7 @@ def add_to_hosts_privileged(domain: str, ip: str = "127.0.0.1") -> None:
151
198
  logger.warning(f"Could not update /etc/hosts via sudo: {e}")
152
199
 
153
200
 
154
- def remove_from_hosts_privileged(domain: str) -> None:
201
+ def remove_from_hosts_privileged(domain: str, password: Optional[str] = None) -> None:
155
202
  """
156
203
  Remove domain entry from /etc/hosts safely.
157
204
  """
@@ -177,7 +224,7 @@ def remove_from_hosts_privileged(domain: str) -> None:
177
224
 
178
225
  if modified:
179
226
  new_content = "\n".join(new_lines) + "\n"
180
- write_privileged_file(hosts_file, new_content)
227
+ write_privileged_file(hosts_file, new_content, password=password)
181
228
  except Exception as e:
182
229
  logger.warning(f"Could not remove {domain} from /etc/hosts: {e}")
183
230
 
@@ -223,7 +270,7 @@ def get_nginx_vhost_dirs() -> Tuple[Path, Optional[Path]]:
223
270
  return fallback, None
224
271
 
225
272
 
226
- def ensure_nginx_sites_enabled_included() -> None:
273
+ def ensure_nginx_sites_enabled_included(password: Optional[str] = None) -> None:
227
274
  """
228
275
  Ensure Nginx includes virtual hosts from /etc/nginx/sites-enabled.
229
276
  If /etc/nginx/nginx.conf already includes sites-enabled, no change is made.
@@ -245,7 +292,7 @@ def ensure_nginx_sites_enabled_included() -> None:
245
292
  )
246
293
  try:
247
294
  if not bridge_conf.exists() or bridge_conf.read_text(encoding="utf-8", errors="ignore") != bridge_content:
248
- write_privileged_file(bridge_conf, bridge_content)
295
+ write_privileged_file(bridge_conf, bridge_content, password=password)
249
296
  except Exception as e:
250
297
  logger.debug(f"Could not create sites-enabled bridge configuration: {e}")
251
298
 
@@ -377,7 +424,7 @@ server {{
377
424
  """
378
425
 
379
426
 
380
- def reload_nginx() -> bool:
427
+ def reload_nginx(password: Optional[str] = None) -> bool:
381
428
  """
382
429
  Reload Nginx configuration safely using available init system or nginx CLI.
383
430
  """
@@ -395,7 +442,9 @@ def reload_nginx() -> bool:
395
442
  return False
396
443
 
397
444
  if os.geteuid() != 0:
398
- cmd = ["sudo"] + cmd
445
+ if password:
446
+ validate_sudo_password(password)
447
+ cmd = ["sudo", "-n"] + cmd
399
448
 
400
449
  try:
401
450
  res = subprocess.run(cmd, capture_output=True, timeout=10)
@@ -451,7 +500,7 @@ def get_vhost(domain: str) -> Optional[Dict[str, Any]]:
451
500
  return None
452
501
 
453
502
 
454
- def remove_vhost(domain: str) -> bool:
503
+ def remove_vhost(domain: str, password: Optional[str] = None) -> bool:
455
504
  """Remove vhost configuration file(s), symlinks, and /etc/hosts entry."""
456
505
  clean_domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
457
506
  removed = False
@@ -459,7 +508,7 @@ def remove_vhost(domain: str) -> bool:
459
508
  conf_file = sdir / f"{clean_domain}.conf"
460
509
  if conf_file.exists() or conf_file.is_symlink():
461
510
  try:
462
- unlink_privileged(conf_file)
511
+ unlink_privileged(conf_file, password=password)
463
512
  removed = True
464
513
  except Exception as e:
465
514
  logger.warning(f"Could not remove {conf_file}: {e}")
@@ -474,9 +523,9 @@ def remove_vhost(domain: str) -> bool:
474
523
  pass
475
524
 
476
525
  # Remove from /etc/hosts
477
- remove_from_hosts_privileged(clean_domain)
526
+ remove_from_hosts_privileged(clean_domain, password=password)
478
527
 
479
528
  # Reload nginx
480
- reload_nginx()
529
+ reload_nginx(password=password)
481
530
 
482
531
  return removed
@@ -248,13 +248,53 @@ def _pick_directory_os(initial_path: Optional[str] = None) -> Optional[str]:
248
248
  return None
249
249
 
250
250
 
251
+ class SudoPasswordModal(ModalScreen[Optional[str]]):
252
+ """Modal dialog to prompt for administrator (sudo) password when needed."""
253
+
254
+ def __init__(self, action_name: str = "This operation") -> None:
255
+ super().__init__()
256
+ self.action_name = action_name
257
+
258
+ def compose(self) -> ComposeResult:
259
+ with Vertical(id="modal-dialog"):
260
+ yield Label("🔐 Administrator Authentication", id="modal-title")
261
+ yield Label(f"{self.action_name} requires administrator privileges.", classes="modal-label")
262
+ yield Label("Enter your sudo password:", classes="modal-label")
263
+ yield Input(password=True, placeholder="Password", id="input-sudo-pass", classes="modal-input")
264
+ with Horizontal(id="modal-buttons"):
265
+ yield Button("Authenticate", variant="primary", id="btn-auth-confirm")
266
+ yield Button("Cancel", variant="default", id="btn-auth-cancel")
267
+
268
+ def on_input_submitted(self, event: Input.Submitted) -> None:
269
+ self.query_one("#btn-auth-confirm", Button).press()
270
+
271
+ def on_button_pressed(self, event: Button.Pressed) -> None:
272
+ if event.button.id == "btn-auth-confirm":
273
+ pwd_input = self.query_one("#input-sudo-pass", Input)
274
+ pwd = pwd_input.value
275
+ if not pwd:
276
+ self.notify("Password is required.", severity="error")
277
+ pwd_input.focus()
278
+ return
279
+ from ndev.linux.runtime.nginx_helper import validate_sudo_password
280
+ if not validate_sudo_password(pwd):
281
+ self.notify("Incorrect administrator password. Please try again.", severity="error")
282
+ pwd_input.value = ""
283
+ pwd_input.focus()
284
+ return
285
+ self.dismiss(pwd)
286
+ else:
287
+ self.dismiss(None)
288
+
289
+
251
290
  class CreateVhostModal(ModalScreen[Optional[dict]]):
252
291
  """Modal dialog to create a new virtual host with local SSL on Linux."""
253
292
 
254
- def __init__(self, installed_phps: list[str], default_php: Optional[str] = None) -> None:
293
+ def __init__(self, installed_phps: list[str], default_php: Optional[str] = None, needs_sudo: bool = False) -> None:
255
294
  super().__init__()
256
295
  self.installed_phps = installed_phps
257
296
  self.default_php = default_php or (installed_phps[0] if installed_phps else "8.4")
297
+ self.needs_sudo = needs_sudo
258
298
  self._user_edited_root = False
259
299
 
260
300
  def compose(self) -> ComposeResult:
@@ -273,10 +313,23 @@ class CreateVhostModal(ModalScreen[Optional[dict]]):
273
313
  yield Label("PHP Version:", classes="modal-label")
274
314
  yield Select(php_options, value=initial_php, id="select-vhost-php", classes="modal-input")
275
315
  yield Checkbox("Enable Local SSL (HTTPS with mkcert)", value=True, id="chk-vhost-ssl")
316
+
317
+ if self.needs_sudo:
318
+ yield Label("🔐 Administrator (sudo) Password:", classes="modal-label")
319
+ yield Input(
320
+ placeholder="Enter sudo password for /etc/nginx & /etc/hosts",
321
+ password=True,
322
+ id="input-vhost-password",
323
+ classes="modal-input"
324
+ )
325
+
276
326
  with Horizontal(id="modal-buttons"):
277
327
  yield Button("Create VHost", variant="success", id="btn-modal-create")
278
328
  yield Button("Cancel", variant="default", id="btn-modal-cancel")
279
329
 
330
+ def on_input_submitted(self, event: Input.Submitted) -> None:
331
+ self.query_one("#btn-modal-create", Button).press()
332
+
280
333
  def on_input_changed(self, event: Input.Changed) -> None:
281
334
  if event.input.id == "input-vhost-domain":
282
335
  domain = event.value.strip()
@@ -318,11 +371,27 @@ class CreateVhostModal(ModalScreen[Optional[dict]]):
318
371
  self.notify("Document root directory is required.", severity="error")
319
372
  return
320
373
 
374
+ password = None
375
+ if self.needs_sudo:
376
+ pwd_input = self.query_one("#input-vhost-password", Input)
377
+ password = pwd_input.value
378
+ if not password:
379
+ self.notify("Administrator (sudo) password is required.", severity="error")
380
+ pwd_input.focus()
381
+ return
382
+ from ndev.linux.runtime.nginx_helper import validate_sudo_password
383
+ if not validate_sudo_password(password):
384
+ self.notify("Incorrect administrator password. Please try again.", severity="error")
385
+ pwd_input.value = ""
386
+ pwd_input.focus()
387
+ return
388
+
321
389
  self.dismiss({
322
390
  "domain": domain,
323
391
  "root": root,
324
392
  "php": php_ver,
325
393
  "ssl": bool(ssl),
394
+ "password": password,
326
395
  })
327
396
  else:
328
397
  self.dismiss(None)
@@ -443,6 +512,16 @@ class NdevDashboard(App):
443
512
  self._updating_selects = False
444
513
  self._available_logs: Dict[str, Path] = {}
445
514
  self._selected_service_key: Optional[str] = None
515
+ self._sudo_password: Optional[str] = None
516
+
517
+ def _needs_sudo(self) -> bool:
518
+ """Check if elevated privileges are needed and require a password prompt."""
519
+ from ndev.linux.runtime.nginx_helper import check_sudo_needs_password, validate_sudo_password
520
+ if not check_sudo_needs_password():
521
+ return False
522
+ if self._sudo_password and validate_sudo_password(self._sudo_password):
523
+ return False
524
+ return True
446
525
 
447
526
  def compose(self) -> ComposeResult:
448
527
  yield Header(show_clock=True)
@@ -959,18 +1038,25 @@ class NdevDashboard(App):
959
1038
  async def action_reload_nginx(self) -> None:
960
1039
  """Reload Nginx configuration."""
961
1040
  self.log_message("[bold blue]Reloading Nginx configuration...[/bold blue]")
962
- try:
963
- def _reload():
964
- if shutil.which("systemctl"):
965
- subprocess.run(["sudo", "systemctl", "reload", "nginx"], check=True)
966
- else:
967
- subprocess.run(["sudo", "service", "nginx", "reload"], check=True)
968
- await asyncio.to_thread(_reload)
969
- self.log_message("[bold green]✓ Nginx configuration reloaded successfully.[/bold green]")
970
- self.notify("Nginx reloaded.", severity="information")
971
- except Exception as e:
972
- self.log_message(f"[bold red]✗ Failed to reload Nginx: {e}[/bold red]")
973
- self.notify(f"Nginx reload failed: {e}", severity="error")
1041
+
1042
+ def _do_reload(pwd: Optional[str] = None) -> None:
1043
+ from ndev.linux.runtime.nginx_helper import reload_nginx
1044
+ ok = reload_nginx(password=pwd or self._sudo_password)
1045
+ if ok:
1046
+ self.log_message("[bold green]✓ Nginx configuration reloaded successfully.[/bold green]")
1047
+ self.notify("Nginx reloaded.", severity="information")
1048
+ else:
1049
+ self.log_message("[bold red]✗ Failed to reload Nginx.[/bold red]")
1050
+ self.notify("Nginx reload failed.", severity="error")
1051
+
1052
+ if self._needs_sudo():
1053
+ def _on_pwd(pwd: Optional[str]) -> None:
1054
+ if pwd:
1055
+ self._sudo_password = pwd
1056
+ asyncio.create_task(asyncio.to_thread(_do_reload, pwd))
1057
+ self.push_screen(SudoPasswordModal("Reloading Nginx"), _on_pwd)
1058
+ else:
1059
+ await asyncio.to_thread(_do_reload, self._sudo_password)
974
1060
 
975
1061
  @work(exclusive=True)
976
1062
  async def action_check_upgrades(self) -> None:
@@ -1098,12 +1184,15 @@ class NdevDashboard(App):
1098
1184
 
1099
1185
  def _on_modal_result(res: Optional[dict]) -> None:
1100
1186
  if res:
1101
- self._create_vhost_worker(res["domain"], res["root"], res["php"], res["ssl"])
1187
+ pwd = res.get("password") or self._sudo_password
1188
+ if pwd:
1189
+ self._sudo_password = pwd
1190
+ self._create_vhost_worker(res["domain"], res["root"], res["php"], res["ssl"], password=pwd)
1102
1191
 
1103
- self.push_screen(CreateVhostModal(installed, curr), _on_modal_result)
1192
+ self.push_screen(CreateVhostModal(installed, curr, needs_sudo=self._needs_sudo()), _on_modal_result)
1104
1193
 
1105
1194
  @work(exclusive=True)
1106
- async def _create_vhost_worker(self, domain: str, root: str, php_ver: str, ssl: bool) -> None:
1195
+ async def _create_vhost_worker(self, domain: str, root: str, php_ver: str, ssl: bool, password: Optional[str] = None) -> None:
1107
1196
  self.query_one(TabbedContent).active = "tab-logs"
1108
1197
  self.log_message(f"[bold blue]Creating virtual host '{domain}' (PHP {php_ver}, SSL={ssl})...[/bold blue]")
1109
1198
  self.notify(f"Creating vhost {domain}...", severity="information")
@@ -1122,7 +1211,7 @@ class NdevDashboard(App):
1122
1211
 
1123
1212
  vhost_dir, enabled_dir = get_nginx_vhost_dirs()
1124
1213
  if enabled_dir:
1125
- ensure_nginx_sites_enabled_included()
1214
+ ensure_nginx_sites_enabled_included(password=password)
1126
1215
 
1127
1216
  conf_file = vhost_dir / f"{domain}.conf"
1128
1217
 
@@ -1142,16 +1231,16 @@ class NdevDashboard(App):
1142
1231
  cert_path=cert_path,
1143
1232
  key_path=key_path,
1144
1233
  )
1145
- write_privileged_file(conf_file, tpl)
1234
+ write_privileged_file(conf_file, tpl, password=password)
1146
1235
  if enabled_dir and enabled_dir != vhost_dir:
1147
1236
  link = enabled_dir / f"{domain}.conf"
1148
- symlink_privileged(conf_file, link)
1237
+ symlink_privileged(conf_file, link, password=password)
1149
1238
 
1150
1239
  # Add /etc/hosts entry
1151
- add_to_hosts_privileged(domain)
1240
+ add_to_hosts_privileged(domain, password=password)
1152
1241
 
1153
1242
  # Reload Nginx
1154
- reload_nginx()
1243
+ reload_nginx(password=password)
1155
1244
  return str(conf_file)
1156
1245
 
1157
1246
  try:
@@ -1177,21 +1266,32 @@ class NdevDashboard(App):
1177
1266
  row_key = vhost_table.coordinate_to_cell_key((vhost_table.cursor_row, 0)).row_key
1178
1267
  domain = str(row_key.value)
1179
1268
 
1269
+ def _do_delete(pwd: Optional[str] = None) -> None:
1270
+ if pwd:
1271
+ self._sudo_password = pwd
1272
+ self._delete_vhost_worker(domain, password=pwd or self._sudo_password)
1273
+
1180
1274
  def _on_confirm(confirmed: bool) -> None:
1181
1275
  if confirmed:
1182
- self._delete_vhost_worker(domain)
1276
+ if self._needs_sudo():
1277
+ self.push_screen(
1278
+ SudoPasswordModal(f"Deleting virtual host '{domain}'"),
1279
+ lambda pwd: _do_delete(pwd) if pwd else None
1280
+ )
1281
+ else:
1282
+ _do_delete(self._sudo_password)
1183
1283
 
1184
1284
  msg = f"Are you sure you want to delete virtual host '{domain}'?\n\nThis will remove its Nginx configuration, SSL certificates, and hosts entry."
1185
1285
  self.push_screen(ConfirmActionModal("Delete Virtual Host", msg, confirm_label="Delete VHost", variant="error"), _on_confirm)
1186
1286
 
1187
1287
  @work(exclusive=True)
1188
- async def _delete_vhost_worker(self, domain: str) -> None:
1288
+ async def _delete_vhost_worker(self, domain: str, password: Optional[str] = None) -> None:
1189
1289
  self.log_message(f"[bold yellow]Deleting virtual host '{domain}'...[/bold yellow]")
1190
1290
  self.notify(f"Deleting vhost {domain}...", severity="information")
1191
1291
 
1192
1292
  def _do() -> None:
1193
1293
  from ndev.linux.runtime.nginx_helper import remove_vhost
1194
- remove_vhost(domain)
1294
+ remove_vhost(domain, password=password)
1195
1295
 
1196
1296
  try:
1197
1297
  await asyncio.to_thread(_do)
@@ -1201,6 +1301,8 @@ class NdevDashboard(App):
1201
1301
  except Exception as e:
1202
1302
  self.log_message(f"[bold red]✗ Failed to delete virtual host {domain}: {e}[/bold red]")
1203
1303
  self.notify(f"Delete failed: {e}", severity="error")
1304
+ self.log_message(f"[bold red]✗ Failed to delete virtual host {domain}: {e}[/bold red]")
1305
+ self.notify(f"Delete failed: {e}", severity="error")
1204
1306
 
1205
1307
  def action_install_php(self) -> None:
1206
1308
  """Open modal dialog to install a new PHP runtime."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndev-stack
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows
5
5
  Author-email: Ankit Karan <ankit.karan99@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankitkaran99/ndev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ndev-stack"
7
- version = "0.3.9"
7
+ version = "0.3.10"
8
8
  description = "Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -233,6 +233,28 @@ class TestNginxHelper(unittest.TestCase):
233
233
  self.assertFalse(link_target.exists())
234
234
  self.assertFalse(link_target.is_symlink())
235
235
 
236
+ def test_validate_sudo_password(self):
237
+ with patch("subprocess.run") as mock_run:
238
+ mock_run.return_value = unittest.mock.MagicMock(returncode=0)
239
+ self.assertTrue(nginx_helper.validate_sudo_password("valid_pwd"))
240
+
241
+ mock_run.return_value = unittest.mock.MagicMock(returncode=1)
242
+ self.assertFalse(nginx_helper.validate_sudo_password("invalid_pwd"))
243
+
244
+ self.assertFalse(nginx_helper.validate_sudo_password(""))
245
+
246
+ def test_check_sudo_needs_password(self):
247
+ with patch("os.geteuid", return_value=0):
248
+ self.assertFalse(nginx_helper.check_sudo_needs_password())
249
+
250
+ with patch("os.geteuid", return_value=1000):
251
+ with patch("subprocess.run") as mock_run:
252
+ mock_run.return_value = unittest.mock.MagicMock(returncode=0)
253
+ self.assertFalse(nginx_helper.check_sudo_needs_password())
254
+
255
+ mock_run.return_value = unittest.mock.MagicMock(returncode=1)
256
+ self.assertTrue(nginx_helper.check_sudo_needs_password())
257
+
236
258
  def test_fastcgi_snippet_opensuse(self):
237
259
  with patch.object(Path, "exists", autospec=True) as mock_exists:
238
260
  def side_effect_exists(p):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes