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/win/cli.py ADDED
@@ -0,0 +1,1898 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ if sys.platform == "win32":
16
+ if hasattr(sys.stdout, "reconfigure"):
17
+ try:
18
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
19
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
20
+ except Exception:
21
+ pass
22
+
23
+ from .core import (
24
+ db as db_core,
25
+ ext as ext_core,
26
+ fcgi,
27
+ grok as grok_core,
28
+ logs as logs_core,
29
+ mailpit as mailpit_core,
30
+ mkcert as mkcert_core,
31
+ paths,
32
+ php,
33
+ pma as pma_core,
34
+ services,
35
+ setup as setup_core,
36
+ upgrade as upgrade_core,
37
+ vhost as vhost_core,
38
+ )
39
+ from .core.elevate import is_admin
40
+
41
+ console = Console()
42
+
43
+
44
+ @click.group(invoke_without_command=True)
45
+ @click.pass_context
46
+ def main(ctx: click.Context):
47
+ """ndev: Windows PHP/FastCGI/Nginx/MariaDB developer environment manager."""
48
+ paths.ensure_dirs()
49
+ if ctx.invoked_subcommand is None:
50
+ click.echo(ctx.get_help())
51
+
52
+
53
+ # ---- Core PHP Version Management -------------------------------------------
54
+
55
+ @main.command()
56
+ @click.option("--all", "--archives", "include_archives", is_flag=True, help="Include archived older PHP releases.")
57
+ def available(include_archives):
58
+ """List PHP versions available to install from windows.php.net."""
59
+ releases = php.list_available(include_archives=include_archives)
60
+ if not releases:
61
+ console.print("[yellow]Could not fetch PHP releases. Check internet connectivity.[/yellow]")
62
+ return
63
+
64
+ table = Table(title="Available PHP Versions (windows.php.net)")
65
+ table.add_column("Version", style="bold cyan")
66
+ table.add_column("Type", style="magenta")
67
+ table.add_column("Arch", style="green")
68
+ table.add_column("Toolset")
69
+ table.add_column("Source")
70
+
71
+ for r in releases:
72
+ tag = "TS (Thread Safe)" if r.thread_safe else "NTS (Non-Thread-Safe)"
73
+ src = "Archive" if r.is_archive else "Current"
74
+ table.add_row(r.version, tag, r.arch, r.toolset, src)
75
+
76
+ console.print(table)
77
+
78
+
79
+ @main.command()
80
+ @click.argument("version")
81
+ @click.option("--arch", default="x64", type=click.Choice(["x64", "x86"]))
82
+ @click.option("--thread-safe/--non-thread-safe", default=True, help="Install Thread-Safe (TS) or Non-Thread-Safe (NTS) build.")
83
+ def install(version, arch, thread_safe):
84
+ """Download and install a PHP version (e.g. 8.4, 8.4.25, 7.4)."""
85
+ with console.status(f"[bold green]Resolving PHP {version} ({arch}, {'TS' if thread_safe else 'NTS'})...[/bold green]"):
86
+ release = php.resolve_release(version, arch=arch, thread_safe=thread_safe)
87
+
88
+ if not release:
89
+ raise click.ClickException(
90
+ f"No matching release found for '{version}' ({arch}, {'TS' if thread_safe else 'NTS'}). "
91
+ f"Run `ndev available --all` to check available versions."
92
+ )
93
+
94
+ console.print(f"Downloading [cyan]PHP {release.version}[/cyan] from {release.zip_url} ...")
95
+ with console.status("[bold green]Downloading archive...[/bold green]"):
96
+ zip_path = php.download_release(release)
97
+
98
+ with console.status(f"[bold green]Extracting and configuring PHP {release.version}...[/bold green]"):
99
+ target = php.install(release.version, zip_path)
100
+
101
+ console.print(f"[bold green]Successfully installed PHP {release.version}[/bold green] -> {target}")
102
+ if php.get_current_version() == release.version:
103
+ console.print(f"[green]PHP {release.version} is now the active CLI version.[/green]")
104
+
105
+
106
+ @main.command()
107
+ @click.argument("version")
108
+ def uninstall(version):
109
+ """Remove an installed PHP version."""
110
+ try:
111
+ target_ver = php.resolve_installed(version)
112
+ except FileNotFoundError as e:
113
+ raise click.ClickException(str(e))
114
+
115
+ php.uninstall(target_ver)
116
+ console.print(f"[bold green]Removed PHP {target_ver}[/bold green]")
117
+
118
+
119
+ @main.command(name="list")
120
+ def list_cmd():
121
+ """List locally installed PHP versions and running status."""
122
+ installed = php.list_installed()
123
+ if not installed:
124
+ console.print("[yellow]No PHP versions installed yet. Run `ndev install <version>` (e.g. `ndev install 8.4`).[/yellow]")
125
+ return
126
+
127
+ curr = php.get_current_version()
128
+ table = Table(title="Installed PHP Versions")
129
+ table.add_column("Version", style="bold cyan")
130
+ table.add_column("Active", style="green")
131
+ table.add_column("FastCGI Pool", style="magenta")
132
+ table.add_column("Path")
133
+
134
+ for v in installed:
135
+ is_active = "[bold green]* (active)[/bold green]" if v == curr else ""
136
+ workers = fcgi.status(v)
137
+ pool_status = f"[green]Running ({len(workers)} workers)[/green]" if workers else "[red]Stopped[/red]"
138
+ table.add_row(v, is_active, pool_status, str(paths.version_dir(v)))
139
+
140
+ console.print(table)
141
+
142
+
143
+ @main.command()
144
+ def current():
145
+ """Display the currently active PHP version."""
146
+ v = php.get_current_version()
147
+ if v:
148
+ workers = fcgi.status(v)
149
+ pool_info = f" (FastCGI pool: {len(workers)} worker(s))" if workers else " (FastCGI pool: stopped)"
150
+ console.print(f"Active PHP version: [bold cyan]{v}[/bold cyan]{pool_info}")
151
+ else:
152
+ console.print("[yellow]No active PHP version set -- run `ndev use <version>`[/yellow]")
153
+
154
+
155
+ @main.command(name="use")
156
+ @click.argument("version")
157
+ def use_cmd(version):
158
+ """Set a PHP version as the active CLI binary."""
159
+ try:
160
+ target_ver = php.use(version)
161
+ except FileNotFoundError as e:
162
+ raise click.ClickException(str(e))
163
+
164
+ console.print(f"[bold green]Now using PHP {target_ver}.[/bold green]")
165
+ shim_on_path = str(paths.SHIM_DIR).lower() in os.environ.get("PATH", "").lower()
166
+ if not shim_on_path:
167
+ console.print(f"[yellow]Ensure {paths.SHIM_DIR} is in your system PATH to run `php` directly.[/yellow]")
168
+
169
+
170
+ @main.command()
171
+ def update():
172
+ """Check if installed PHP versions have newer releases available."""
173
+ installed = php.list_installed()
174
+ if not installed:
175
+ console.print("[yellow]No PHP versions installed yet.[/yellow]")
176
+ return
177
+
178
+ available_rels = php.list_available(include_archives=False)
179
+ table = Table(title="PHP Version Update Check")
180
+ table.add_column("Installed", style="bold cyan")
181
+ table.add_column("Latest Available", style="green")
182
+ table.add_column("Status")
183
+
184
+ for v in installed:
185
+ parts = v.split(".")
186
+ mm = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else v
187
+ matching = [r for r in available_rels if r.major_minor == mm]
188
+ if matching:
189
+ latest = matching[0].version
190
+ if latest != v:
191
+ table.add_row(v, latest, f"[bold yellow]Update Available[/bold yellow] (`ndev install {latest}`)")
192
+ else:
193
+ table.add_row(v, latest, "[green]Up to date[/green]")
194
+ else:
195
+ table.add_row(v, "N/A (Archived/Custom)", "[dim]No active feed[/dim]")
196
+
197
+ console.print(table)
198
+
199
+
200
+ @main.command()
201
+ @click.option("--downloads/--no-downloads", default=True, help="Clean downloaded archive cache.")
202
+ @click.option("--run-state/--no-run-state", default=True, help="Clean dead PID and state files.")
203
+ @click.option("--temp/--no-temp", default=True, help="Clean temporary session and cache files.")
204
+ @click.option("--logs/--no-logs", "clean_logs", default=False, help="Truncate or clear log files in nginx and php.")
205
+ def clean(downloads, run_state, temp, clean_logs):
206
+ """Clean up cached downloads, temporary session files, logs, and stale runtime state files."""
207
+ count = 0
208
+ if downloads and paths.DOWNLOADS_DIR.exists():
209
+ for item in paths.DOWNLOADS_DIR.iterdir():
210
+ try:
211
+ if item.is_file():
212
+ item.unlink()
213
+ count += 1
214
+ elif item.is_dir():
215
+ shutil.rmtree(item)
216
+ count += 1
217
+ except Exception:
218
+ pass
219
+
220
+ if temp:
221
+ temp_dir = paths.TEMP_DIR
222
+ if temp_dir.exists():
223
+ for item in temp_dir.rglob("*"):
224
+ if item.is_file():
225
+ try:
226
+ item.unlink()
227
+ count += 1
228
+ except Exception:
229
+ pass
230
+
231
+ if clean_logs:
232
+ if paths.NGINX_LOGS_DIR.exists():
233
+ for p in paths.NGINX_LOGS_DIR.glob("*.log"):
234
+ try:
235
+ p.write_text("", encoding="utf-8")
236
+ count += 1
237
+ except Exception:
238
+ pass
239
+ for v in php.list_installed():
240
+ v_dir = paths.version_dir(v)
241
+ for log_file in [v_dir / "php_error.log", v_dir / "error.log"]:
242
+ if log_file.exists():
243
+ try:
244
+ log_file.write_text("", encoding="utf-8")
245
+ count += 1
246
+ except Exception:
247
+ pass
248
+
249
+ if run_state and paths.RUN_DIR.exists():
250
+ for item in paths.RUN_DIR.glob("*.json"):
251
+ try:
252
+ if item.name.startswith("php_"):
253
+ ver = item.stem.replace("php_", "")
254
+ if not fcgi.status(ver):
255
+ item.unlink(missing_ok=True)
256
+ count += 1
257
+ elif item.name == "mariadb.json":
258
+ if not services.mariadb_is_running():
259
+ item.unlink(missing_ok=True)
260
+ count += 1
261
+ elif item.name == "pma.json":
262
+ if not pma_core.status():
263
+ item.unlink(missing_ok=True)
264
+ count += 1
265
+ except Exception:
266
+ pass
267
+
268
+ console.print(f"[bold green]Cleaned {count} cached/stale files.[/bold green]")
269
+
270
+
271
+ @main.command()
272
+ @click.argument("target", required=False)
273
+ @click.option("--lines", "-n", default=50, help="Number of lines to display.")
274
+ def logs(target, lines):
275
+ """View / tail service and vhost logs."""
276
+ all_logs = logs_core.get_available_logs()
277
+ matched_path = None
278
+
279
+ if not target:
280
+ if not all_logs:
281
+ console.print("[yellow]No active log files found.[/yellow]")
282
+ return
283
+ console.print("\n[bold]Available Log Files[/bold]")
284
+ console.print("-------------------")
285
+ log_items = list(all_logs.items())
286
+ for idx, (name, p) in enumerate(log_items, 1):
287
+ console.print(f" {idx}) {name:<20} ({p})")
288
+ console.print("")
289
+ choice = click.prompt("Select log file index to view", default=1, type=int)
290
+ if 1 <= choice <= len(log_items):
291
+ matched_path = log_items[choice - 1][1]
292
+ else:
293
+ return
294
+ else:
295
+ # Match target
296
+ if target in all_logs:
297
+ matched_path = all_logs[target]
298
+ else:
299
+ for name, p in all_logs.items():
300
+ if target.lower() in name.lower() or target.lower() in p.name.lower():
301
+ matched_path = p
302
+ break
303
+
304
+ if not matched_path:
305
+ # Check direct path in nginx logs
306
+ direct = paths.NGINX_LOGS_DIR / f"{target}.error.log"
307
+ if direct.exists():
308
+ matched_path = direct
309
+ else:
310
+ direct_access = paths.NGINX_LOGS_DIR / f"{target}.access.log"
311
+ if direct_access.exists():
312
+ matched_path = direct_access
313
+
314
+ if not matched_path or not matched_path.exists():
315
+ raise click.ClickException(f"Log file for '{target}' not found.")
316
+
317
+ tail = logs_core.read_log_tail(matched_path, lines=lines)
318
+ console.print(f"\n[bold cyan]--- Showing last {len(tail)} lines of {matched_path} ---[/bold cyan]")
319
+ for line in tail:
320
+ click.echo(line)
321
+
322
+
323
+ @main.command()
324
+ def doctor():
325
+ """Diagnose the ndev environment and components."""
326
+ table = Table(title="ndev Doctor Diagnostic Report")
327
+ table.add_column("Component", style="bold cyan")
328
+ table.add_column("Status")
329
+ table.add_column("Details")
330
+
331
+ table.add_row("ndev Home", "[green]OK[/green]", str(paths.NDEV_HOME))
332
+
333
+ admin_st = "[green]YES[/green]" if is_admin() else "[yellow]NO (Standard User)[/yellow]"
334
+ table.add_row("Running as Admin", admin_st, "UAC elevation is handled on-demand")
335
+
336
+ installed_phps = php.list_installed()
337
+ curr_php = php.get_current_version()
338
+ php_detail = f"Installed: {', '.join(installed_phps) if installed_phps else 'none'} | Active: {curr_php or 'none'}"
339
+ php_status = "[green]OK[/green]" if installed_phps else "[yellow]MISSING[/yellow]"
340
+ table.add_row("PHP Runtimes", php_status, php_detail)
341
+
342
+ # Nginx
343
+ nginx_inst = services.nginx_is_installed()
344
+ if nginx_inst:
345
+ t_res = services.nginx_test_config()
346
+ t_ok = "Config OK" if t_res.returncode == 0 else f"Config Error: {t_res.stderr.strip()}"
347
+ run_st = "Running" if services.nginx_is_running() else "Stopped"
348
+ table.add_row("Nginx Web Server", "[green]OK[/green]", f"{run_st} | {t_ok} ({paths.NGINX_DIR})")
349
+ else:
350
+ table.add_row("Nginx Web Server", "[red]MISSING[/red]", "Run `ndev setup` to install")
351
+
352
+ # MariaDB
353
+ mariadb_inst = services.mariadb_is_installed()
354
+ if mariadb_inst:
355
+ run_st = "Running" if services.mariadb_is_running() else "Stopped"
356
+ table.add_row("MariaDB Server", "[green]OK[/green]", f"{run_st} ({paths.MARIADB_DIR})")
357
+ else:
358
+ table.add_row("MariaDB Server", "[red]MISSING[/red]", "Run `ndev setup` to install")
359
+
360
+ # mkcert
361
+ mkcert_exe = paths.SHIM_DIR / "mkcert.exe"
362
+ if mkcert_exe.exists() or shutil.which("mkcert"):
363
+ ca_ok = mkcert_core.is_ca_installed()
364
+ detail = "Installed and root CA trusted" if ca_ok else "Installed (run `ndev setup` to trust root CA)"
365
+ table.add_row("mkcert (Local SSL)", "[green]OK[/green]", detail)
366
+ else:
367
+ table.add_row("mkcert (Local SSL)", "[yellow]MISSING[/yellow]", "Run `ndev setup` to install")
368
+
369
+ # ngrok
370
+ ngrok_exe = paths.SHIM_DIR / "ngrok.exe"
371
+ if ngrok_exe.exists() or shutil.which("ngrok"):
372
+ table.add_row("ngrok Tunneling", "[green]OK[/green]", "Installed and available for public tunnels")
373
+ else:
374
+ table.add_row("ngrok Tunneling", "[yellow]MISSING[/yellow]", "Run `ndev setup` to install")
375
+
376
+ # Composer
377
+ composer_bat = paths.SHIM_DIR / "composer.bat"
378
+ if composer_bat.exists() or shutil.which("composer"):
379
+ table.add_row("Composer", "[green]OK[/green]", "Installed and available on CLI")
380
+ else:
381
+ table.add_row("Composer", "[yellow]MISSING[/yellow]", "Run `ndev setup` to install")
382
+
383
+ # Root CA Bundle
384
+ if paths.CACERT_PATH.exists():
385
+ table.add_row("Root CA Bundle", "[green]OK[/green]", f"Available for cURL/OpenSSL ({paths.CACERT_PATH.name})")
386
+ else:
387
+ table.add_row("Root CA Bundle", "[yellow]MISSING[/yellow]", "Run `ndev setup` to download cacert.pem")
388
+
389
+ # PATH Check
390
+ shim_on_path = str(paths.SHIM_DIR).lower() in os.environ.get("PATH", "").lower()
391
+ path_status = "[green]OK[/green]" if shim_on_path else "[yellow]WARNING[/yellow]"
392
+ path_detail = f"Found on PATH ({paths.SHIM_DIR})" if shim_on_path else f"Add {paths.SHIM_DIR} to your system PATH"
393
+ table.add_row("Shims on PATH", path_status, path_detail)
394
+
395
+ console.print(table)
396
+
397
+
398
+ # ---- Top-Level Service Commands (start / stop / restart / reload / status) ---
399
+
400
+ def _resolve_target(target: str | None) -> str:
401
+ if not target:
402
+ curr = php.get_current_version()
403
+ if curr:
404
+ return curr
405
+ installed = php.list_installed()
406
+ if installed:
407
+ return installed[-1]
408
+ raise click.ClickException("No target or active PHP version specified.")
409
+ try:
410
+ return php.resolve_installed(target)
411
+ except Exception:
412
+ return target
413
+
414
+
415
+ @main.command()
416
+ @click.argument("target", required=False)
417
+ def start(target):
418
+ """Start a service (e.g. `8.4`, `pma`, `nginx`, `mariadb`, or `all`)."""
419
+ t = (target or "").lower()
420
+ if t == "all":
421
+ services.nginx_start()
422
+ services.mariadb_start()
423
+ try:
424
+ pma_core.start()
425
+ except Exception:
426
+ pass
427
+ for v in php.list_installed():
428
+ cfg = paths.load_config()
429
+ try:
430
+ fcgi.start(v, php.php_cgi_exe(v), cfg["fcgi_workers_per_version"], cfg["fcgi_base_port"])
431
+ except Exception:
432
+ pass
433
+ console.print("[bold green]Started all services.[/bold green]")
434
+ return
435
+
436
+ if t in ["pma", "phpmyadmin"]:
437
+ if pma_core.status():
438
+ st = pma_core.status()
439
+ console.print(f"[yellow]phpMyAdmin is already running at {st.get('url', 'http://127.0.0.1:8080')} (PID {st.get('pid')})[/yellow]")
440
+ return
441
+ pid = pma_core.start()
442
+ console.print(f"[bold green]phpMyAdmin started at http://127.0.0.1:8080 (PID {pid})[/bold green]")
443
+ return
444
+
445
+ if t in ["mailpit", "mail"]:
446
+ st = mailpit_core.status()
447
+ if st:
448
+ console.print(f"[yellow]Mailpit is already running at {st.get('url', 'http://127.0.0.1:8025')} (PID {st.get('pid')})[/yellow]")
449
+ return
450
+ if not mailpit_core.is_installed():
451
+ console.print("[yellow]Mailpit not installed - downloading now...[/yellow]")
452
+ with console.status("[bold green]Downloading Mailpit...[/bold green]"):
453
+ mailpit_core.install()
454
+ pid = mailpit_core.start()
455
+ console.print(f"[bold green]Mailpit started at http://127.0.0.1:8025 (PID {pid})[/bold green]")
456
+ return
457
+
458
+ if t == "nginx":
459
+ if services.nginx_is_running():
460
+ console.print("[yellow]Nginx is already running.[/yellow]")
461
+ return
462
+ services.nginx_start()
463
+ console.print("[bold green]Nginx started.[/bold green]")
464
+ return
465
+
466
+ if t in ["mariadb", "mysql"]:
467
+ if services.mariadb_is_running():
468
+ st = services.mariadb_status()
469
+ pid_str = f" (PID {st.get('pid')})" if st and st.get('pid') else ""
470
+ console.print(f"[yellow]MariaDB is already running{pid_str}.[/yellow]")
471
+ return
472
+ pid = services.mariadb_start()
473
+ console.print(f"[bold green]MariaDB started (PID {pid}).[/bold green]")
474
+ return
475
+
476
+ # Treat as PHP version
477
+ v = _resolve_target(target)
478
+ existing = fcgi.status(v)
479
+ if existing:
480
+ ports = ", ".join(str(w.port) for w in existing)
481
+ console.print(f"[yellow]PHP {v} FastCGI pool is already running ({len(existing)} worker(s) on ports: {ports})[/yellow]")
482
+ return
483
+ cfg = paths.load_config()
484
+ workers = fcgi.start(v, php.php_cgi_exe(v), cfg["fcgi_workers_per_version"], cfg["fcgi_base_port"])
485
+ ports = ", ".join(str(w.port) for w in workers)
486
+ console.print(f"[bold green]Started PHP {v} FastCGI pool ({len(workers)} workers on ports: {ports})[/bold green]")
487
+
488
+
489
+ @main.command()
490
+ @click.argument("target", required=False)
491
+ def stop(target):
492
+ """Stop a service (e.g. `8.4`, `pma`, `nginx`, `mariadb`, or `all`)."""
493
+ t = (target or "").lower()
494
+ if t == "all":
495
+ services.nginx_stop()
496
+ services.mariadb_stop()
497
+ pma_core.stop()
498
+ for v in php.list_installed():
499
+ fcgi.stop(v)
500
+ console.print("[bold green]Stopped all services.[/bold green]")
501
+ return
502
+
503
+ if t in ["pma", "phpmyadmin"]:
504
+ if not pma_core.status():
505
+ console.print("[yellow]phpMyAdmin is not running.[/yellow]")
506
+ return
507
+ pma_core.stop()
508
+ console.print("[bold green]phpMyAdmin stopped.[/bold green]")
509
+ return
510
+
511
+ if t in ["mailpit", "mail"]:
512
+ if not mailpit_core.status():
513
+ console.print("[yellow]Mailpit is not running.[/yellow]")
514
+ return
515
+ mailpit_core.stop()
516
+ console.print("[bold green]Mailpit stopped.[/bold green]")
517
+ return
518
+
519
+ if t == "nginx":
520
+ if not services.nginx_is_running():
521
+ console.print("[yellow]Nginx is not running.[/yellow]")
522
+ return
523
+ services.nginx_stop()
524
+ console.print("[bold green]Nginx stopped.[/bold green]")
525
+ return
526
+
527
+ if t in ["mariadb", "mysql"]:
528
+ if not services.mariadb_is_running():
529
+ console.print("[yellow]MariaDB is not running.[/yellow]")
530
+ return
531
+ services.mariadb_stop()
532
+ console.print("[bold green]MariaDB stopped.[/bold green]")
533
+ return
534
+
535
+ v = _resolve_target(target)
536
+ if not fcgi.status(v):
537
+ console.print(f"[yellow]PHP {v} FastCGI pool is not running.[/yellow]")
538
+ return
539
+ fcgi.stop(v)
540
+ console.print(f"[bold green]Stopped PHP {v} FastCGI pool.[/bold green]")
541
+
542
+
543
+ @main.command()
544
+ @click.argument("target", required=False)
545
+ def restart(target):
546
+ """Restart a service (e.g. `8.4`, `pma`, `mailpit`, `nginx`, `mariadb`, or `all`)."""
547
+ t = (target or "").lower()
548
+ if t == "all":
549
+ services.nginx_stop()
550
+ services.mariadb_stop()
551
+ pma_core.stop()
552
+ for v in php.list_installed():
553
+ fcgi.stop(v)
554
+ services.nginx_start()
555
+ services.mariadb_start()
556
+ try:
557
+ pma_core.start()
558
+ except Exception:
559
+ pass
560
+ for v in php.list_installed():
561
+ cfg = paths.load_config()
562
+ try:
563
+ fcgi.start(v, php.php_cgi_exe(v), cfg["fcgi_workers_per_version"], cfg["fcgi_base_port"])
564
+ except Exception:
565
+ pass
566
+ console.print("[bold green]Restarted all services.[/bold green]")
567
+ return
568
+
569
+ if t in ["pma", "phpmyadmin"]:
570
+ pid = pma_core.restart()
571
+ console.print(f"[bold green]phpMyAdmin restarted at http://127.0.0.1:8080 (PID {pid})[/bold green]")
572
+ return
573
+
574
+ if t in ["mailpit", "mail"]:
575
+ if not mailpit_core.is_installed():
576
+ raise click.ClickException("Mailpit is not installed. Run `ndev mailpit install` first.")
577
+ pid = mailpit_core.restart()
578
+ console.print(f"[bold green]Mailpit restarted at http://127.0.0.1:8025 (PID {pid})[/bold green]")
579
+ return
580
+
581
+ if t == "nginx":
582
+ services.nginx_stop()
583
+ services.nginx_start()
584
+ console.print("[bold green]Nginx restarted.[/bold green]")
585
+ return
586
+
587
+ if t in ["mariadb", "mysql"]:
588
+ services.mariadb_stop()
589
+ pid = services.mariadb_start()
590
+ console.print(f"[bold green]MariaDB restarted (PID {pid}).[/bold green]")
591
+ return
592
+
593
+ v = _resolve_target(target)
594
+ workers = fcgi.restart(v)
595
+ ports = ", ".join(str(w.port) for w in workers)
596
+ console.print(f"[bold green]Restarted PHP {v} FastCGI pool ({len(workers)} workers on ports: {ports})[/bold green]")
597
+
598
+
599
+ @main.command()
600
+ @click.argument("target", required=False)
601
+ def reload(target):
602
+ """Reload service configuration (e.g. Nginx or PHP)."""
603
+ t = (target or "nginx").lower()
604
+ if t == "nginx":
605
+ services.nginx_reload()
606
+ console.print("[bold green]Nginx configuration reloaded.[/bold green]")
607
+ return
608
+
609
+ # Default to restarting target
610
+ restart.callback(target)
611
+
612
+
613
+ @main.command()
614
+ @click.argument("target", required=False)
615
+ def status(target):
616
+ """Show status of all services or a specific target."""
617
+ if target:
618
+ t = target.lower()
619
+ if t in ["pma", "phpmyadmin"]:
620
+ st = pma_core.status()
621
+ if st:
622
+ console.print(f"phpMyAdmin: [bold green]Running[/bold green] at {st['url']} (PID {st['pid']})")
623
+ else:
624
+ console.print("phpMyAdmin: [bold red]Stopped[/bold red]")
625
+ return
626
+
627
+ if t in ["mailpit", "mail"]:
628
+ st = mailpit_core.status()
629
+ if st:
630
+ console.print(f"Mailpit: [bold green]Running[/bold green] at {st['url']} | SMTP {st['smtp']} (PID {st['pid']})")
631
+ else:
632
+ console.print("Mailpit: [bold red]Stopped[/bold red]")
633
+ return
634
+
635
+ if t == "nginx":
636
+ run = services.nginx_is_running()
637
+ console.print(f"Nginx: {'[bold green]Running[/bold green]' if run else '[bold red]Stopped[/bold red]'}")
638
+ return
639
+
640
+ if t in ["mariadb", "mysql"]:
641
+ st = services.mariadb_status()
642
+ if st and st.get("running"):
643
+ console.print(f"MariaDB: [bold green]Running[/bold green] (PID {st['pid']})")
644
+ else:
645
+ console.print("MariaDB: [bold red]Stopped[/bold red]")
646
+ return
647
+
648
+ # PHP target
649
+ v = _resolve_target(target)
650
+ workers = fcgi.status(v)
651
+ if workers:
652
+ ports = ", ".join(str(w.port) for w in workers)
653
+ console.print(f"PHP {v}: [bold green]Running[/bold green] ({len(workers)} workers on ports: {ports})")
654
+ else:
655
+ console.print(f"PHP {v}: [bold red]Stopped[/bold red]")
656
+ return
657
+
658
+ # Overall status table
659
+ table = Table(title="ndev Service Status Dashboard")
660
+ table.add_column("Service", style="bold cyan")
661
+ table.add_column("Type", style="magenta")
662
+ table.add_column("Status")
663
+ table.add_column("Details")
664
+
665
+ # Nginx
666
+ if services.nginx_is_installed():
667
+ ng_run = services.nginx_is_running()
668
+ table.add_row(
669
+ "Nginx",
670
+ "Web Server",
671
+ "[bold green]RUNNING[/bold green]" if ng_run else "[bold red]STOPPED[/bold red]",
672
+ f"Config dir: {paths.NGINX_CONF_D}"
673
+ )
674
+ else:
675
+ table.add_row("Nginx", "Web Server", "[yellow]NOT INSTALLED[/yellow]", "Run `ndev setup`")
676
+
677
+ # MariaDB
678
+ if services.mariadb_is_installed():
679
+ mb_st = services.mariadb_status()
680
+ mb_run = mb_st and mb_st.get("running")
681
+ mb_detail = f"PID {mb_st['pid']} (port 3306)" if mb_run else f"Data: {paths.MARIADB_DIR / 'data'}"
682
+ table.add_row(
683
+ "MariaDB",
684
+ "Database",
685
+ "[bold green]RUNNING[/bold green]" if mb_run else "[bold red]STOPPED[/bold red]",
686
+ mb_detail
687
+ )
688
+ else:
689
+ table.add_row("MariaDB", "Database", "[yellow]NOT INSTALLED[/yellow]", "Run `ndev setup`")
690
+
691
+ # phpMyAdmin
692
+ pma_st = pma_core.status()
693
+ if pma_st:
694
+ table.add_row("phpMyAdmin", "Admin Tool", "[bold green]RUNNING[/bold green]", f"{pma_st['url']} (PID {pma_st['pid']})")
695
+ else:
696
+ pma_installed = (paths.PMA_DIR / "index.php").exists()
697
+ table.add_row("phpMyAdmin", "Admin Tool", "[bold red]STOPPED[/bold red]" if pma_installed else "[dim]NOT INSTALLED[/dim]", "http://127.0.0.1:8080")
698
+
699
+ # Mailpit
700
+ mp_st = mailpit_core.status()
701
+ if mp_st:
702
+ table.add_row("Mailpit", "Email Sandbox", "[bold green]RUNNING[/bold green]", f"{mp_st['url']} | SMTP {mp_st['smtp']} (PID {mp_st['pid']})")
703
+ elif mailpit_core.is_installed():
704
+ table.add_row("Mailpit", "Email Sandbox", "[bold red]STOPPED[/bold red]", f"http://127.0.0.1:{mailpit_core.DEFAULT_WEB_PORT} | SMTP 127.0.0.1:{mailpit_core.DEFAULT_SMTP_PORT}")
705
+
706
+ # PHP versions
707
+ curr = php.get_current_version()
708
+ installed_phps = php.list_installed()
709
+ if not installed_phps:
710
+ table.add_row("PHP (CLI/Pool)", "FastCGI Pool", "[yellow]NOT INSTALLED[/yellow]", "Run `ndev install <version>`")
711
+ else:
712
+ for v in installed_phps:
713
+ workers = fcgi.status(v)
714
+ is_act = " [bold green](active CLI)[/bold green]" if v == curr else ""
715
+ if workers:
716
+ table.add_row(
717
+ f"PHP {v}{is_act}",
718
+ "FastCGI Pool",
719
+ "[bold green]RUNNING[/bold green]",
720
+ f"{len(workers)} worker(s) | Ports: {', '.join(str(w.port) for w in workers)}"
721
+ )
722
+ else:
723
+ table.add_row(
724
+ f"PHP {v}{is_act}",
725
+ "FastCGI Pool",
726
+ "[bold red]STOPPED[/bold red]",
727
+ f"Binary: {php.php_exe(v)}"
728
+ )
729
+
730
+ console.print(table)
731
+
732
+
733
+ # ---- Interactive Terminal UI Dashboard (ui / tui / dashboard) --------------
734
+
735
+ @main.command(name="ui")
736
+ def ui_cmd():
737
+ """Launch the interactive Textual TUI dashboard."""
738
+ from .tui import run_dashboard
739
+ run_dashboard()
740
+
741
+
742
+ @main.command(name="tui", hidden=True)
743
+ def tui_alias():
744
+ """Alias for ui."""
745
+ ui_cmd.callback()
746
+
747
+
748
+ @main.command(name="dashboard", hidden=True)
749
+ def dashboard_alias():
750
+ """Alias for ui."""
751
+ ui_cmd.callback()
752
+
753
+
754
+ # ---- Interactive Control Dashboard (ctl) -----------------------------------
755
+
756
+ @main.group(invoke_without_command=True)
757
+ @click.pass_context
758
+ def ctl(ctx: click.Context):
759
+ """Interactive dashboard and process control for Nginx, MariaDB, and PHP."""
760
+ if ctx.invoked_subcommand is not None:
761
+ return
762
+
763
+ console.print("\n[bold blue]==================================================================[/bold blue]")
764
+ console.print("[bold blue] ndev Web Services Control [/bold blue]")
765
+ console.print("[bold blue]==================================================================[/bold blue]\n")
766
+
767
+ status.callback(None)
768
+ console.print("")
769
+ console.print("[bold]Select Action:[/bold]")
770
+ console.print(" 1) Restart (Default)")
771
+ console.print(" 2) Start")
772
+ console.print(" 3) Stop")
773
+ console.print(" 4) Reload Nginx")
774
+
775
+ action_choice = click.prompt("Enter choice [1-4]", default=1, type=int)
776
+ action_map = {1: "restart", 2: "start", 3: "stop", 4: "reload"}
777
+ act = action_map.get(action_choice, "restart")
778
+
779
+ if act == "reload":
780
+ reload.callback("nginx")
781
+ return
782
+
783
+ console.print("\n[bold]Select Service:[/bold]")
784
+ console.print(" 1) Nginx")
785
+ console.print(" 2) MariaDB")
786
+ console.print(" 3) phpMyAdmin (pma)")
787
+ console.print(" 4) PHP FastCGI Pool")
788
+ console.print(" 5) All Services")
789
+ svc_choice = click.prompt("Enter choice [1-5]", default=5, type=int)
790
+
791
+ curr_php = php.get_current_version()
792
+ php_versions = php.list_installed()
793
+
794
+ if svc_choice == 1:
795
+ if act == "start":
796
+ start.callback("nginx")
797
+ elif act == "stop":
798
+ stop.callback("nginx")
799
+ elif act == "restart":
800
+ restart.callback("nginx")
801
+ return
802
+
803
+ elif svc_choice == 2:
804
+ if act == "start":
805
+ start.callback("mariadb")
806
+ elif act == "stop":
807
+ stop.callback("mariadb")
808
+ elif act == "restart":
809
+ restart.callback("mariadb")
810
+ return
811
+
812
+ elif svc_choice == 3:
813
+ if act == "start":
814
+ start.callback("pma")
815
+ elif act == "stop":
816
+ stop.callback("pma")
817
+ elif act == "restart":
818
+ restart.callback("pma")
819
+ return
820
+
821
+ elif svc_choice == 4:
822
+ if not php_versions:
823
+ console.print("[yellow]No PHP versions installed. Run `ndev install <version>` first.[/yellow]")
824
+ return
825
+ console.print("\n[bold]Select PHP FastCGI Pool:[/bold]")
826
+ default_idx = 1
827
+ for idx, pv in enumerate(php_versions, 1):
828
+ is_act = " [bold green](active CLI)[/bold green]" if pv == curr_php else ""
829
+ if pv == curr_php:
830
+ default_idx = idx
831
+ console.print(f" {idx}) PHP {pv}{is_act}")
832
+ all_idx = len(php_versions) + 1
833
+ console.print(f" {all_idx}) All Installed PHP Pools")
834
+
835
+ php_choice = click.prompt("Enter choice", default=default_idx, type=int)
836
+ if php_choice == all_idx:
837
+ for pv in php_versions:
838
+ if act == "start":
839
+ start.callback(pv)
840
+ elif act == "stop":
841
+ stop.callback(pv)
842
+ elif act == "restart":
843
+ restart.callback(pv)
844
+ elif 1 <= php_choice <= len(php_versions):
845
+ target_pv = php_versions[php_choice - 1]
846
+ if act == "start":
847
+ start.callback(target_pv)
848
+ elif act == "stop":
849
+ stop.callback(target_pv)
850
+ elif act == "restart":
851
+ restart.callback(target_pv)
852
+ return
853
+
854
+ elif svc_choice == 5:
855
+ # All Services
856
+ selected_php_pools = []
857
+ if php_versions:
858
+ console.print("\n[bold]Select PHP FastCGI Pool to include with All Services:[/bold]")
859
+ default_idx = 1
860
+ for idx, pv in enumerate(php_versions, 1):
861
+ is_act = " [bold green](active CLI)[/bold green]" if pv == curr_php else ""
862
+ if pv == curr_php:
863
+ default_idx = idx
864
+ console.print(f" {idx}) PHP {pv}{is_act}")
865
+ all_idx = len(php_versions) + 1
866
+ none_idx = len(php_versions) + 2
867
+ console.print(f" {all_idx}) All Installed PHP Pools")
868
+ console.print(f" {none_idx}) None (Web & Database Only)")
869
+
870
+ php_choice = click.prompt("Enter choice", default=default_idx, type=int)
871
+ if php_choice == all_idx:
872
+ selected_php_pools = list(php_versions)
873
+ elif 1 <= php_choice <= len(php_versions):
874
+ selected_php_pools = [php_versions[php_choice - 1]]
875
+ else:
876
+ selected_php_pools = []
877
+
878
+ console.print(f"\n[bold blue]Executing '{act}' on selected services...[/bold blue]")
879
+
880
+ # Process Nginx
881
+ try:
882
+ if act == "start":
883
+ services.nginx_start()
884
+ console.print("[bold green]✓ Nginx started[/bold green]")
885
+ elif act == "stop":
886
+ services.nginx_stop()
887
+ console.print("[bold green]✓ Nginx stopped[/bold green]")
888
+ elif act == "restart":
889
+ services.nginx_stop()
890
+ services.nginx_start()
891
+ console.print("[bold green]✓ Nginx restarted[/bold green]")
892
+ except Exception as e:
893
+ console.print(f"[bold red]✗ Nginx error: {e}[/bold red]")
894
+
895
+ # Process MariaDB
896
+ try:
897
+ if act == "start":
898
+ pid = services.mariadb_start()
899
+ console.print(f"[bold green]✓ MariaDB started (PID {pid})[/bold green]")
900
+ elif act == "stop":
901
+ services.mariadb_stop()
902
+ console.print("[bold green]✓ MariaDB stopped[/bold green]")
903
+ elif act == "restart":
904
+ services.mariadb_stop()
905
+ pid = services.mariadb_start()
906
+ console.print(f"[bold green]✓ MariaDB restarted (PID {pid})[/bold green]")
907
+ except Exception as e:
908
+ console.print(f"[bold red]✗ MariaDB error: {e}[/bold red]")
909
+
910
+ # Process phpMyAdmin
911
+ try:
912
+ if (paths.PMA_DIR / "index.php").exists():
913
+ if act == "start":
914
+ pid = pma_core.start()
915
+ console.print(f"[bold green]✓ phpMyAdmin started at http://127.0.0.1:8080 (PID {pid})[/bold green]")
916
+ elif act == "stop":
917
+ pma_core.stop()
918
+ console.print("[bold green]✓ phpMyAdmin stopped[/bold green]")
919
+ elif act == "restart":
920
+ pid = pma_core.restart()
921
+ console.print(f"[bold green]✓ phpMyAdmin restarted at http://127.0.0.1:8080 (PID {pid})[/bold green]")
922
+ except Exception as e:
923
+ console.print(f"[yellow]! phpMyAdmin note: {e}[/yellow]")
924
+
925
+ # Process selected PHP FastCGI pools
926
+ for pv in selected_php_pools:
927
+ try:
928
+ cfg = paths.load_config()
929
+ if act == "start":
930
+ workers = fcgi.start(pv, php.php_cgi_exe(pv), cfg["fcgi_workers_per_version"], cfg["fcgi_base_port"])
931
+ ports = ", ".join(str(w.port) for w in workers)
932
+ console.print(f"[bold green]✓ PHP {pv} FastCGI pool started ({len(workers)} workers on ports: {ports})[/bold green]")
933
+ elif act == "stop":
934
+ fcgi.stop(pv)
935
+ console.print(f"[bold green]✓ PHP {pv} FastCGI pool stopped[/bold green]")
936
+ elif act == "restart":
937
+ workers = fcgi.restart(pv)
938
+ ports = ", ".join(str(w.port) for w in workers)
939
+ console.print(f"[bold green]✓ PHP {pv} FastCGI pool restarted ({len(workers)} workers on ports: {ports})[/bold green]")
940
+ except Exception as e:
941
+ console.print(f"[bold red]✗ PHP {pv} FastCGI pool error: {e}[/bold red]")
942
+
943
+ console.print(f"\n[bold green]All requested service operations completed.[/bold green]")
944
+
945
+
946
+
947
+
948
+ @ctl.command(name="start-nginx")
949
+ def ctl_start_nginx():
950
+ start.callback("nginx")
951
+
952
+
953
+ @ctl.command(name="stop-nginx")
954
+ def ctl_stop_nginx():
955
+ stop.callback("nginx")
956
+
957
+
958
+ @ctl.command(name="reload-nginx")
959
+ def ctl_reload_nginx():
960
+ reload.callback("nginx")
961
+
962
+
963
+ @ctl.command(name="start-mariadb")
964
+ def ctl_start_mariadb():
965
+ start.callback("mariadb")
966
+
967
+
968
+ @ctl.command(name="stop-mariadb")
969
+ def ctl_stop_mariadb():
970
+ stop.callback("mariadb")
971
+
972
+
973
+ @ctl.command(name="status")
974
+ def ctl_status():
975
+ status.callback(None)
976
+
977
+
978
+ # ---- FastCGI Pool Subcommands (pool) ---------------------------------------
979
+
980
+ @main.group()
981
+ def pool():
982
+ """Manage php-cgi worker pools."""
983
+
984
+
985
+ @pool.command(name="start")
986
+ @click.argument("version")
987
+ @click.option("--workers", default=None, type=int)
988
+ def pool_start(version, workers):
989
+ target_ver = php.resolve_installed(version)
990
+ cfg = paths.load_config()
991
+ n = workers or cfg["fcgi_workers_per_version"]
992
+ state = fcgi.start(target_ver, php.php_cgi_exe(target_ver), n, cfg["fcgi_base_port"])
993
+ ports = ", ".join(str(w.port) for w in state)
994
+ console.print(f"[bold green]Started {len(state)} workers for PHP {target_ver} on ports: {ports}[/bold green]")
995
+
996
+
997
+ @pool.command(name="stop")
998
+ @click.argument("version")
999
+ def pool_stop(version):
1000
+ target_ver = php.resolve_installed(version)
1001
+ fcgi.stop(target_ver)
1002
+ console.print(f"[bold green]Stopped FastCGI pool for PHP {target_ver}[/bold green]")
1003
+
1004
+
1005
+ @pool.command(name="restart")
1006
+ @click.argument("version")
1007
+ @click.option("--workers", default=None, type=int)
1008
+ def pool_restart(version, workers):
1009
+ target_ver = php.resolve_installed(version)
1010
+ state = fcgi.restart(target_ver, workers=workers)
1011
+ ports = ", ".join(str(w.port) for w in state)
1012
+ console.print(f"[bold green]Restarted {len(state)} workers for PHP {target_ver} on ports: {ports}[/bold green]")
1013
+
1014
+
1015
+ @pool.command(name="status")
1016
+ @click.argument("version")
1017
+ def pool_status(version):
1018
+ target_ver = php.resolve_installed(version)
1019
+ workers = fcgi.status(target_ver)
1020
+ if workers:
1021
+ for w in workers:
1022
+ console.print(f"PID: {w.pid:<8} Port: {w.port}")
1023
+ else:
1024
+ console.print(f"PHP {target_ver} pool is stopped.")
1025
+
1026
+
1027
+ # ---- Virtual Host Management (vhost) ---------------------------------------
1028
+
1029
+ @main.command(name="vhost")
1030
+ @click.option("--domain", "-d", default=None, help="Domain name (e.g. project.local)")
1031
+ @click.option("--root", "-r", default=None, help="Project root directory")
1032
+ @click.option("--php", "php_version", default=None, help="PHP version (e.g. 8.4)")
1033
+ @click.option("--ssl/--no-ssl", default=None, help="Enable SSL/HTTPS with local mkcert certificate")
1034
+ @click.option("--start-pool/--no-start-pool", default=True, help="Auto-start PHP FastCGI pool if stopped")
1035
+ @click.option("--force", "-f", is_flag=True, help="Overwrite/update existing virtual host without confirmation")
1036
+ def vhost_cmd(domain, root, php_version, ssl, start_pool, force):
1037
+ """Create or update an Nginx virtual host with local SSL and hosts file mapping."""
1038
+ installed_phps = php.list_installed()
1039
+ if not installed_phps:
1040
+ raise click.ClickException("No PHP versions installed. Run `ndev install <version>` first.")
1041
+
1042
+ # Interactive prompts if arguments omitted
1043
+ is_interactive = not domain
1044
+ if not domain:
1045
+ domain = click.prompt("Domain name (e.g. myproject.local)")
1046
+
1047
+ # Strip any protocol prefixes and slashes
1048
+ domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
1049
+
1050
+ # Check if virtual host already exists
1051
+ existing_vhost = None
1052
+ for v in vhost_core.list_vhosts():
1053
+ if v["domain"].lower() == domain.lower():
1054
+ existing_vhost = v
1055
+ break
1056
+
1057
+ is_update = bool(existing_vhost)
1058
+ if existing_vhost and not force:
1059
+ if is_interactive:
1060
+ console.print(f"\n[yellow]Virtual host '{domain}' already exists:[/yellow]")
1061
+ console.print(f" • Root: {existing_vhost['root']}")
1062
+ console.print(f" • PHP : {existing_vhost['php']}")
1063
+ console.print(f" • SSL : {'Enabled' if existing_vhost['ssl'] else 'Disabled'}")
1064
+ if not click.confirm(f"Do you want to update/reconfigure '{domain}'?", default=False):
1065
+ console.print("[yellow]Aborted. Existing virtual host was not modified.[/yellow]")
1066
+ return
1067
+ else:
1068
+ raise click.ClickException(f"Virtual host '{domain}' already exists. Use --force to overwrite/update.")
1069
+
1070
+ default_root = existing_vhost["root"] if existing_vhost else str(Path.home() / "Sites" / domain)
1071
+ if not root:
1072
+ root = click.prompt("Project root directory", default=default_root)
1073
+
1074
+ default_php = existing_vhost["php"] if (existing_vhost and existing_vhost["php"] in installed_phps) else (php.get_current_version() or installed_phps[-1])
1075
+ if not php_version:
1076
+ console.print(f"Available PHP versions: {', '.join(installed_phps)}")
1077
+ while True:
1078
+ php_version = click.prompt("Select PHP version", default=default_php)
1079
+ if php_version in installed_phps:
1080
+ break
1081
+ console.print(f"[bold red]PHP {php_version} is not installed.[/bold red] Available: {', '.join(installed_phps)}")
1082
+ elif php_version not in installed_phps:
1083
+ raise click.ClickException(
1084
+ f"PHP {php_version} is not installed. Available versions: {', '.join(installed_phps)}"
1085
+ )
1086
+
1087
+ default_ssl = existing_vhost["ssl"] if existing_vhost else False
1088
+ if ssl is None:
1089
+ ssl = click.confirm("Enable SSL/HTTPS with local certificate?", default=default_ssl)
1090
+
1091
+ conf_path = vhost_core.create_vhost(
1092
+ domain=domain,
1093
+ root=root,
1094
+ php_version=php_version,
1095
+ ssl=ssl,
1096
+ auto_start_pool=start_pool,
1097
+ )
1098
+
1099
+ action_label = "Updated" if is_update else "Created"
1100
+ console.print(f"\n[bold green]Virtual Host {action_label} Successfully[/bold green]")
1101
+ console.print(f"Domain : [bold cyan]{domain}[/bold cyan]")
1102
+ console.print(f"Root : {root}")
1103
+ console.print(f"PHP : {php_version}")
1104
+ console.print(f"Config : {conf_path}")
1105
+ if ssl:
1106
+ console.print(f"URL : [bold green]https://{domain}[/bold green]")
1107
+ else:
1108
+ console.print(f"URL : [bold green]http://{domain}[/bold green]")
1109
+
1110
+
1111
+ @main.command(name="vhost-remove")
1112
+ @click.option("--domain", "-d", default=None, help="Domain name to remove")
1113
+ def vhost_remove_cmd(domain):
1114
+ """Remove a virtual host configuration and hosts file mapping."""
1115
+ if not domain:
1116
+ vhosts = vhost_core.list_vhosts()
1117
+ if not vhosts:
1118
+ console.print("[yellow]No virtual hosts configured.[/yellow]")
1119
+ return
1120
+ console.print("\n[bold]Select Virtual Host to Remove:[/bold]")
1121
+ for idx, v in enumerate(vhosts, 1):
1122
+ console.print(f" {idx}) {v['domain']} (Root: {v['root']})")
1123
+ choice = click.prompt("Enter choice", default=1, type=int)
1124
+ if 1 <= choice <= len(vhosts):
1125
+ domain = vhosts[choice - 1]["domain"]
1126
+ else:
1127
+ return
1128
+
1129
+ domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
1130
+ removed = vhost_core.remove_vhost(domain)
1131
+ if removed:
1132
+ console.print(f"[bold green]Removed virtual host '{domain}'.[/bold green]")
1133
+ else:
1134
+ console.print(f"[yellow]Virtual host '{domain}' does not exist.[/yellow]")
1135
+
1136
+
1137
+ @main.command(name="vhost-list")
1138
+ def vhost_list_cmd():
1139
+ """List all configured virtual hosts."""
1140
+ vhosts = vhost_core.list_vhosts()
1141
+ if not vhosts:
1142
+ console.print("[yellow]No virtual hosts configured yet. Run `ndev vhost` to create one.[/yellow]")
1143
+ return
1144
+
1145
+ table = Table(title="Configured Virtual Hosts")
1146
+ table.add_column("Domain", style="bold cyan")
1147
+ table.add_column("URL", style="bold green")
1148
+ table.add_column("Root Directory")
1149
+ table.add_column("PHP", style="magenta")
1150
+ table.add_column("SSL")
1151
+
1152
+ for v in vhosts:
1153
+ url = f"https://{v['domain']}" if v["ssl"] else f"http://{v['domain']}"
1154
+ ssl_tag = "[green]Enabled[/green]" if v["ssl"] else "[dim]Disabled[/dim]"
1155
+ table.add_row(v["domain"], url, v["root"], v["php"], ssl_tag)
1156
+
1157
+ console.print(table)
1158
+
1159
+
1160
+ # ---- Database Management (db) ----------------------------------------------
1161
+
1162
+ @main.group(invoke_without_command=True)
1163
+ @click.pass_context
1164
+ def db(ctx: click.Context):
1165
+ """Manage MariaDB databases and users."""
1166
+ if ctx.invoked_subcommand is not None:
1167
+ return
1168
+
1169
+ # Interactive Wizard matching Linux ndev db
1170
+ console.print("\n[bold blue]========================================[/bold blue]")
1171
+ console.print("[bold blue] ndev Database Manager Wizard [/bold blue]")
1172
+ console.print("[bold blue]========================================[/bold blue]\n")
1173
+
1174
+ console.print("Operation:")
1175
+ console.print(" 1) Create Database")
1176
+ console.print(" 2) Drop Database")
1177
+ console.print(" 3) Export/Dump Database")
1178
+ console.print(" 4) Import Database (SQL file)")
1179
+ console.print(" 5) Create User")
1180
+ console.print(" 6) Drop User")
1181
+ console.print(" 7) List Databases")
1182
+ console.print(" 8) List Users")
1183
+
1184
+ op_choice = click.prompt("Choice [1-8]", default=1, type=int)
1185
+ root_pass = click.prompt("Admin password", default=db_core.DEFAULT_ROOT_PASSWORD, hide_input=True)
1186
+
1187
+ # Validate database connection before proceeding with operations
1188
+ ok, err = db_core.test_connection(root_password=root_pass)
1189
+ if not ok:
1190
+ console.print(f"\n[bold red]✗ Connection Error:[/bold red] {err}")
1191
+ retry = click.confirm("Would you like to re-enter the admin password?", default=True)
1192
+ if retry:
1193
+ root_pass = click.prompt("Admin password", hide_input=True)
1194
+ ok, err = db_core.test_connection(root_password=root_pass)
1195
+ if not ok:
1196
+ console.print(f"[bold red]✗ Connection failed: {err}[/bold red]")
1197
+ return
1198
+ else:
1199
+ return
1200
+
1201
+ try:
1202
+ if op_choice == 1:
1203
+ name = click.prompt("Database name")
1204
+ owner = click.prompt("User to grant privileges to (optional)", default="")
1205
+ db_core.create_db(name, owner=owner, root_password=root_pass)
1206
+ console.print(f"[bold green]✓ Database '{name}' created successfully.[/bold green]")
1207
+ elif op_choice == 2:
1208
+ name = click.prompt("Database name to drop")
1209
+ confirm = click.prompt(f"Type '{name}' to confirm deletion")
1210
+ if confirm == name:
1211
+ db_core.drop_db(name, root_password=root_pass)
1212
+ console.print(f"[bold green]✓ Database '{name}' dropped.[/bold green]")
1213
+ else:
1214
+ console.print("[yellow]Aborted.[/yellow]")
1215
+ elif op_choice == 3:
1216
+ name = click.prompt("Database name to export")
1217
+ out_path = click.prompt("Output SQL file path", default=f"{name}.sql")
1218
+ saved = db_core.export_db(name, output_path=out_path, root_password=root_pass)
1219
+ console.print(f"[bold green]✓ Database exported to: {saved}[/bold green]")
1220
+ elif op_choice == 4:
1221
+ name = click.prompt("Database name to import into")
1222
+ sql_file = click.prompt("Path to .sql file to import")
1223
+ db_core.import_db(name, sql_file, root_password=root_pass)
1224
+ console.print(f"[bold green]✓ Database '{name}' imported from {sql_file}.[/bold green]")
1225
+ elif op_choice == 5:
1226
+ user = click.prompt("Username")
1227
+ password = click.prompt("Password", hide_input=True)
1228
+ grant = click.prompt("Database to grant privileges to (optional)", default="")
1229
+ db_core.create_user(user, password, grant_db=grant or None, root_password=root_pass)
1230
+ console.print(f"[bold green]✓ User '{user}' created successfully.[/bold green]")
1231
+ elif op_choice == 6:
1232
+ user = click.prompt("Username to drop")
1233
+ confirm = click.prompt(f"Type '{user}' to confirm deletion")
1234
+ if confirm == user:
1235
+ db_core.drop_user(user, root_password=root_pass)
1236
+ console.print(f"[bold green]✓ User '{user}' dropped.[/bold green]")
1237
+ else:
1238
+ console.print("[yellow]Aborted.[/yellow]")
1239
+ elif op_choice == 7:
1240
+ dbs = db_core.list_databases(root_password=root_pass)
1241
+ console.print("\n[bold]Databases:[/bold]")
1242
+ for d in dbs:
1243
+ console.print(f" • {d}")
1244
+ elif op_choice == 8:
1245
+ users = db_core.list_users(root_password=root_pass)
1246
+ console.print("\n[bold]Users:[/bold]")
1247
+ for u in users:
1248
+ console.print(f" • {u}")
1249
+ except Exception as e:
1250
+ console.print(f"\n[bold red]✗ Database Error:[/bold red] {e}")
1251
+
1252
+
1253
+ @db.command(name="create-db")
1254
+ @click.argument("name")
1255
+ @click.option("--owner", default="", help="User to grant privileges on this database")
1256
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1257
+ def db_create_db(name, owner, root_password):
1258
+ """Create a new database."""
1259
+ try:
1260
+ db_core.create_db(name, owner=owner, root_password=root_password)
1261
+ console.print(f"[bold green]✓ Database `{name}` created.[/bold green]")
1262
+ except Exception as e:
1263
+ raise click.ClickException(str(e))
1264
+
1265
+
1266
+ @db.command(name="create", hidden=True)
1267
+ @click.argument("name")
1268
+ @click.option("--owner", default="", help="User to grant privileges on this database")
1269
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1270
+ def db_create_alias(name, owner, root_password):
1271
+ """Alias for create-db."""
1272
+ db_create_db.callback(name, owner, root_password)
1273
+
1274
+
1275
+ @db.command(name="drop-db")
1276
+ @click.argument("name")
1277
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
1278
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1279
+ def db_drop_db(name, force, root_password):
1280
+ """Drop an existing database."""
1281
+ if not force:
1282
+ confirm = click.prompt(f"Type '{name}' to confirm dropping database")
1283
+ if confirm != name:
1284
+ console.print("[yellow]Aborted.[/yellow]")
1285
+ return
1286
+ try:
1287
+ db_core.drop_db(name, root_password)
1288
+ console.print(f"[bold green]✓ Database `{name}` dropped.[/bold green]")
1289
+ except Exception as e:
1290
+ raise click.ClickException(str(e))
1291
+
1292
+
1293
+ @db.command(name="drop", hidden=True)
1294
+ @click.argument("name")
1295
+ @click.option("--force", "-f", is_flag=True)
1296
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1297
+ def db_drop_alias(name, force, root_password):
1298
+ """Alias for drop-db."""
1299
+ db_drop_db.callback(name, force, root_password)
1300
+
1301
+
1302
+ @db.command(name="export-db")
1303
+ @click.argument("name")
1304
+ @click.option("--output", "-o", default=None, help="Output SQL file path")
1305
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1306
+ def db_export_db(name, output, root_password):
1307
+ """Export/dump a database to SQL file."""
1308
+ out = output or f"{name}.sql"
1309
+ try:
1310
+ saved = db_core.export_db(name, output_path=out, root_password=root_password)
1311
+ console.print(f"[bold green]✓ Database `{name}` exported to {saved}[/bold green]")
1312
+ except Exception as e:
1313
+ raise click.ClickException(str(e))
1314
+
1315
+
1316
+ @db.command(name="export", hidden=True)
1317
+ @click.argument("name")
1318
+ @click.option("--output", "-o", default=None)
1319
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1320
+ def db_export_alias(name, output, root_password):
1321
+ """Alias for export-db."""
1322
+ db_export_db.callback(name, output, root_password)
1323
+
1324
+
1325
+ @db.command(name="dump", hidden=True)
1326
+ @click.argument("name")
1327
+ @click.option("--output", "-o", default=None)
1328
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1329
+ def db_dump_alias(name, output, root_password):
1330
+ """Alias for export-db."""
1331
+ db_export_db.callback(name, output, root_password)
1332
+
1333
+
1334
+ @db.command(name="import-db")
1335
+ @click.argument("name")
1336
+ @click.argument("sql_file", type=click.Path(exists=True))
1337
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1338
+ def db_import_db(name, sql_file, root_password):
1339
+ """Import a .sql file into a database."""
1340
+ try:
1341
+ db_core.import_db(name, sql_file, root_password=root_password)
1342
+ console.print(f"[bold green]✓ Database `{name}` imported from {sql_file}[/bold green]")
1343
+ except Exception as e:
1344
+ raise click.ClickException(str(e))
1345
+
1346
+
1347
+ @db.command(name="import", hidden=True)
1348
+ @click.argument("name")
1349
+ @click.argument("sql_file", type=click.Path(exists=True))
1350
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1351
+ def db_import_alias(name, sql_file, root_password):
1352
+ """Alias for import-db."""
1353
+ db_import_db.callback(name, sql_file, root_password)
1354
+
1355
+
1356
+ @db.command(name="list")
1357
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1358
+ def db_list(root_password):
1359
+ """List databases."""
1360
+ try:
1361
+ for name in db_core.list_databases(root_password):
1362
+ console.print(f" • {name}")
1363
+ except Exception as e:
1364
+ raise click.ClickException(str(e))
1365
+
1366
+
1367
+ @db.command(name="create-user")
1368
+ @click.argument("username")
1369
+ @click.option("--new-password", prompt=True, hide_input=True)
1370
+ @click.option("--grant-db", default=None)
1371
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1372
+ def db_create_user(username, new_password, grant_db, root_password):
1373
+ """Create a database user."""
1374
+ try:
1375
+ db_core.create_user(username, new_password, grant_db, root_password=root_password)
1376
+ suffix = f" with privileges on `{grant_db}`" if grant_db else ""
1377
+ console.print(f"[bold green]✓ User '{username}' created{suffix}.[/bold green]")
1378
+ except Exception as e:
1379
+ raise click.ClickException(str(e))
1380
+
1381
+
1382
+ @db.command(name="drop-user")
1383
+ @click.argument("username")
1384
+ @click.option("--force", "-f", is_flag=True)
1385
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1386
+ def db_drop_user(username, force, root_password):
1387
+ """Drop a database user."""
1388
+ if not force:
1389
+ confirm = click.prompt(f"Type '{username}' to confirm dropping user")
1390
+ if confirm != username:
1391
+ console.print("[yellow]Aborted.[/yellow]")
1392
+ return
1393
+ try:
1394
+ db_core.drop_user(username, root_password=root_password)
1395
+ console.print(f"[bold green]✓ User '{username}' dropped.[/bold green]")
1396
+ except Exception as e:
1397
+ raise click.ClickException(str(e))
1398
+
1399
+
1400
+ @db.command(name="list-users")
1401
+ @click.option("--root-password", default=db_core.DEFAULT_ROOT_PASSWORD)
1402
+ def db_list_users(root_password):
1403
+ """List database users."""
1404
+ try:
1405
+ for name in db_core.list_users(root_password):
1406
+ console.print(f" • {name}")
1407
+ except Exception as e:
1408
+ raise click.ClickException(str(e))
1409
+
1410
+
1411
+ # ---- Extension Manager (ext) -----------------------------------------------
1412
+
1413
+ @main.group()
1414
+ def ext():
1415
+ """Manage PECL extensions (precompiled DLLs for Windows)."""
1416
+
1417
+
1418
+ @ext.command(name="install")
1419
+ @click.argument("name")
1420
+ @click.argument("ext_version", required=False)
1421
+ @click.argument("php_version", required=False)
1422
+ @click.option("--arch", default="x64", type=click.Choice(["x64", "x86"]))
1423
+ def ext_install(name, ext_version, php_version, arch):
1424
+ """Install and enable a PECL extension."""
1425
+ target_php = php_version
1426
+ target_ext_ver = ext_version
1427
+
1428
+ # If only 2 args passed (e.g. `ndev ext install redis 8.4`)
1429
+ if ext_version and not php_version:
1430
+ installed = php.list_installed()
1431
+ if ext_version in installed or any(v.startswith(ext_version) for v in installed):
1432
+ target_php = ext_version
1433
+ target_ext_ver = None
1434
+
1435
+ if not target_php:
1436
+ target_php = php.get_current_version()
1437
+ if not target_php:
1438
+ installed = php.list_installed()
1439
+ if installed:
1440
+ target_php = installed[-1]
1441
+ else:
1442
+ raise click.ClickException("No PHP version active. Specify PHP version as argument.")
1443
+
1444
+ with console.status(f"[bold green]Finding and installing extension '{name}' for PHP {target_php}...[/bold green]"):
1445
+ ext_dir = ext_core.install(name, target_ext_ver, target_php, arch=arch)
1446
+
1447
+ console.print(f"[bold green]Installed and enabled {name} for PHP {target_php}[/bold green] -> {ext_dir}")
1448
+
1449
+
1450
+ @ext.command(name="enable")
1451
+ @click.argument("name")
1452
+ @click.argument("php_version", required=False)
1453
+ def ext_enable(name, php_version):
1454
+ """Enable an extension in php.ini."""
1455
+ v = php_version or php.get_current_version()
1456
+ if not v:
1457
+ raise click.ClickException("No active PHP version. Pass PHP version explicitly.")
1458
+ target_v = php.resolve_installed(v)
1459
+ ext_core.enable(name, target_v)
1460
+ console.print(f"[bold green]Enabled {name} for PHP {target_v}[/bold green]")
1461
+
1462
+
1463
+ @ext.command(name="disable")
1464
+ @click.argument("name")
1465
+ @click.argument("php_version", required=False)
1466
+ def ext_disable(name, php_version):
1467
+ """Disable an extension in php.ini."""
1468
+ v = php_version or php.get_current_version()
1469
+ if not v:
1470
+ raise click.ClickException("No active PHP version. Pass PHP version explicitly.")
1471
+ target_v = php.resolve_installed(v)
1472
+ ext_core.disable(name, target_v)
1473
+ console.print(f"[bold green]Disabled {name} for PHP {target_v}[/bold green]")
1474
+
1475
+
1476
+ @ext.command(name="uninstall")
1477
+ @click.argument("name")
1478
+ @click.argument("php_version", required=False)
1479
+ def ext_uninstall(name, php_version):
1480
+ """Disable and remove an extension DLL."""
1481
+ v = php_version or php.get_current_version()
1482
+ if not v:
1483
+ raise click.ClickException("No active PHP version. Pass PHP version explicitly.")
1484
+ target_v = php.resolve_installed(v)
1485
+ ext_core.uninstall(name, target_v)
1486
+ console.print(f"[bold green]Uninstalled {name} for PHP {target_v}[/bold green]")
1487
+
1488
+
1489
+ @ext.command(name="list")
1490
+ @click.argument("php_version", required=False)
1491
+ def ext_list(php_version):
1492
+ """List loaded and configured extensions for a PHP version."""
1493
+ v = php_version or php.get_current_version()
1494
+ if not v:
1495
+ raise click.ClickException("No active PHP version. Pass PHP version explicitly.")
1496
+ target_v = php.resolve_installed(v)
1497
+
1498
+ table = Table(title=f"PHP {target_v} Extensions")
1499
+ table.add_column("Extension", style="bold cyan")
1500
+ table.add_column("Status")
1501
+
1502
+ for name, enabled in ext_core.list_status(target_v).items():
1503
+ st = "[bold green]enabled[/bold green]" if enabled else "[dim]disabled[/dim]"
1504
+ table.add_row(name, st)
1505
+
1506
+ console.print(table)
1507
+
1508
+
1509
+ @ext.command(name="available")
1510
+ @click.argument("name")
1511
+ def ext_available(name):
1512
+ """List available versions for an extension on PECL."""
1513
+ versions = ext_core.list_ext_versions(name)
1514
+ console.print(f"[bold]Available versions for {name} on PECL ({len(versions)} total):[/bold]")
1515
+ for v in versions[-20:]:
1516
+ console.print(f" {v}")
1517
+
1518
+
1519
+ # ---- phpMyAdmin Service (pma) ----------------------------------------------
1520
+
1521
+ @main.group()
1522
+ def pma():
1523
+ """Manage phpMyAdmin background service."""
1524
+
1525
+
1526
+ @pma.command(name="install")
1527
+ @click.option("--version", default=pma_core.DEFAULT_VERSION)
1528
+ def pma_install(version):
1529
+ with console.status(f"[bold green]Installing phpMyAdmin {version}...[/bold green]"):
1530
+ path = pma_core.install(version)
1531
+ console.print(f"[bold green]Installed phpMyAdmin {version}[/bold green] -> {path}")
1532
+
1533
+
1534
+ @pma.command(name="start")
1535
+ @click.option("--php", "php_version", default=None)
1536
+ @click.option("--port", default=pma_core.DEFAULT_PORT)
1537
+ def pma_start(php_version, port):
1538
+ pid = pma_core.start(php_version, port)
1539
+ console.print(f"[bold green]phpMyAdmin running at http://127.0.0.1:{port} (PID {pid})[/bold green]")
1540
+
1541
+
1542
+ @pma.command(name="stop")
1543
+ def pma_stop():
1544
+ pma_core.stop()
1545
+ console.print("[bold green]phpMyAdmin stopped.[/bold green]")
1546
+
1547
+
1548
+ @pma.command(name="restart")
1549
+ @click.option("--php", "php_version", default=None)
1550
+ @click.option("--port", default=pma_core.DEFAULT_PORT)
1551
+ def pma_restart(php_version, port):
1552
+ pid = pma_core.restart(php_version, port)
1553
+ console.print(f"[bold green]phpMyAdmin restarted at http://127.0.0.1:{port} (PID {pid})[/bold green]")
1554
+
1555
+
1556
+ @pma.command(name="status")
1557
+ def pma_status():
1558
+ st = pma_core.status()
1559
+ if st:
1560
+ console.print(f"[bold green]Running:[/bold green] {st['url']} (PID {st['pid']})")
1561
+ else:
1562
+ console.print("[bold red]Stopped.[/bold red]")
1563
+
1564
+
1565
+ # ---- Mailpit - local email sandbox (mailpit) --------------------------------
1566
+
1567
+ @main.group()
1568
+ def mailpit():
1569
+ """Manage Mailpit - local email sandbox & SMTP catcher.
1570
+
1571
+ \b
1572
+ Mailpit catches every e-mail your app sends locally so you can
1573
+ inspect it without ever hitting a real inbox.
1574
+
1575
+ \b
1576
+ After starting:
1577
+ SMTP server -> 127.0.0.1:1025 (point your app here)
1578
+ Web UI -> http://127.0.0.1:8025 (browse caught mail)
1579
+ """
1580
+
1581
+
1582
+ @mailpit.command(name="install")
1583
+ def mailpit_install():
1584
+ """Download the prebuilt Mailpit binary from GitHub releases."""
1585
+ with console.status("[bold green]Fetching latest Mailpit release...[/bold green]"):
1586
+ try:
1587
+ path = mailpit_core.install()
1588
+ except Exception as e:
1589
+ raise click.ClickException(str(e))
1590
+ console.print(f"[bold green]Mailpit installed[/bold green] -> {path}")
1591
+
1592
+
1593
+ @mailpit.command(name="start")
1594
+ @click.option("--smtp-port", default=mailpit_core.DEFAULT_SMTP_PORT, show_default=True,
1595
+ help="SMTP listening port")
1596
+ @click.option("--web-port", default=mailpit_core.DEFAULT_WEB_PORT, show_default=True,
1597
+ help="Web UI listening port")
1598
+ def mailpit_start(smtp_port, web_port):
1599
+ """Start Mailpit email sandbox in the background."""
1600
+ if not mailpit_core.is_installed():
1601
+ console.print("[yellow]Mailpit not installed - downloading now...[/yellow]")
1602
+ with console.status("[bold green]Downloading Mailpit...[/bold green]"):
1603
+ try:
1604
+ mailpit_core.install()
1605
+ except Exception as e:
1606
+ raise click.ClickException(str(e))
1607
+
1608
+ st = mailpit_core.status()
1609
+ if st:
1610
+ console.print(
1611
+ f"[yellow]Mailpit is already running at {st['url']} "
1612
+ f"(SMTP {st['smtp']}, PID {st['pid']})[/yellow]"
1613
+ )
1614
+ return
1615
+ try:
1616
+ pid = mailpit_core.start(smtp_port=smtp_port, web_port=web_port)
1617
+ except Exception as e:
1618
+ raise click.ClickException(str(e))
1619
+
1620
+ console.print(f"[bold green]Mailpit started (PID {pid})[/bold green]")
1621
+ console.print(f" Web UI -> [bold cyan]http://127.0.0.1:{web_port}[/bold cyan]")
1622
+ console.print(f" SMTP -> [bold cyan]127.0.0.1:{smtp_port}[/bold cyan]")
1623
+
1624
+
1625
+ @mailpit.command(name="stop")
1626
+ def mailpit_stop():
1627
+ """Stop the running Mailpit process."""
1628
+ st = mailpit_core.status()
1629
+ if not st:
1630
+ console.print("[yellow]Mailpit is not running.[/yellow]")
1631
+ return
1632
+ try:
1633
+ mailpit_core.stop()
1634
+ except Exception as e:
1635
+ raise click.ClickException(str(e))
1636
+ console.print("[bold green]Mailpit stopped.[/bold green]")
1637
+
1638
+
1639
+ @mailpit.command(name="restart")
1640
+ @click.option("--smtp-port", default=mailpit_core.DEFAULT_SMTP_PORT, show_default=True)
1641
+ @click.option("--web-port", default=mailpit_core.DEFAULT_WEB_PORT, show_default=True)
1642
+ def mailpit_restart(smtp_port, web_port):
1643
+ """Restart Mailpit."""
1644
+ if not mailpit_core.is_installed():
1645
+ raise click.ClickException("Mailpit is not installed. Run `ndev mailpit install` first.")
1646
+ try:
1647
+ pid = mailpit_core.restart(smtp_port=smtp_port, web_port=web_port)
1648
+ except Exception as e:
1649
+ raise click.ClickException(str(e))
1650
+ console.print(f"[bold green]Mailpit restarted (PID {pid})[/bold green]")
1651
+ console.print(f" Web UI -> [bold cyan]http://127.0.0.1:{web_port}[/bold cyan]")
1652
+ console.print(f" SMTP -> [bold cyan]127.0.0.1:{smtp_port}[/bold cyan]")
1653
+
1654
+
1655
+ @mailpit.command(name="status")
1656
+ def mailpit_status():
1657
+ """Show Mailpit status."""
1658
+ st = mailpit_core.status()
1659
+ if st:
1660
+ console.print(f"[bold green]Running[/bold green] Web UI -> {st['url']} | SMTP -> {st['smtp']} | PID {st['pid']}")
1661
+ else:
1662
+ console.print("[bold red]Stopped.[/bold red]")
1663
+ if not mailpit_core.is_installed():
1664
+ console.print("[yellow] (not installed - run `ndev mailpit install`)[/yellow]")
1665
+
1666
+
1667
+ @mailpit.command(name="launch")
1668
+ @click.option("--smtp-port", default=mailpit_core.DEFAULT_SMTP_PORT, show_default=True,
1669
+ help="SMTP listening port")
1670
+ @click.option("--web-port", default=mailpit_core.DEFAULT_WEB_PORT, show_default=True,
1671
+ help="Web UI listening port")
1672
+ def mailpit_launch(smtp_port, web_port):
1673
+ """Open Mailpit web UI in your default browser (starts service if stopped)."""
1674
+ import webbrowser
1675
+ st = mailpit_core.status()
1676
+ if not st:
1677
+ if not mailpit_core.is_installed():
1678
+ console.print("[yellow]Mailpit not installed - downloading now...[/yellow]")
1679
+ with console.status("[bold green]Downloading Mailpit...[/bold green]"):
1680
+ try:
1681
+ mailpit_core.install()
1682
+ except Exception as e:
1683
+ raise click.ClickException(str(e))
1684
+ try:
1685
+ pid = mailpit_core.start(smtp_port=smtp_port, web_port=web_port)
1686
+ console.print(f"[bold green]✓ Mailpit started (PID {pid})[/bold green]")
1687
+ except Exception as e:
1688
+ raise click.ClickException(str(e))
1689
+ st = mailpit_core.status()
1690
+
1691
+ url = st["url"] if st else f"http://127.0.0.1:{web_port}"
1692
+ console.print(f"Opening [bold cyan]{url}[/bold cyan] in browser...")
1693
+ webbrowser.open(url)
1694
+
1695
+
1696
+ @mailpit.command(name="open", hidden=True)
1697
+ @click.option("--smtp-port", default=mailpit_core.DEFAULT_SMTP_PORT)
1698
+ @click.option("--web-port", default=mailpit_core.DEFAULT_WEB_PORT)
1699
+ def mailpit_open_alias(smtp_port, web_port):
1700
+ """Alias for launch."""
1701
+ mailpit_launch.callback(smtp_port, web_port)
1702
+
1703
+
1704
+ # ---- ngrok Tunneling (grok / tunnel / share) --------------------------------
1705
+
1706
+ @main.command(name="grok")
1707
+ @click.option("--domain", default=None, help="Vhost domain to tunnel. Omit to select interactively.")
1708
+ @click.option("--ssl", is_flag=True, help="Tunnel HTTPS port 443")
1709
+ def grok(domain, ssl):
1710
+ """Tunnel a local virtual host to the public web via ngrok."""
1711
+ if not domain:
1712
+ vhosts = grok_core.list_vhosts()
1713
+ if not vhosts:
1714
+ console.print("[yellow]No virtual hosts configured yet -- run `ndev vhost` first.[/yellow]")
1715
+ return
1716
+ console.print("\n[bold]Available Virtual Hosts[/bold]")
1717
+ console.print("----------------------")
1718
+ for i, v in enumerate(vhosts, 1):
1719
+ console.print(f" {i}) {v}")
1720
+ console.print("")
1721
+ choice = click.prompt("Select vhost index", default=1, type=int)
1722
+ if 1 <= choice <= len(vhosts):
1723
+ domain = vhosts[choice - 1]
1724
+ else:
1725
+ raise click.ClickException("Invalid selection.")
1726
+
1727
+ domain = re.sub(r"^https?://", "", domain.strip().lower()).rstrip("/")
1728
+ console.print(f"Starting ngrok tunnel for [bold cyan]{domain}[/bold cyan]{' (HTTPS)' if ssl else ''} -- Ctrl+C to stop.")
1729
+ proc = grok_core.start_tunnel(domain, ssl=ssl)
1730
+ try:
1731
+ proc.wait()
1732
+ except KeyboardInterrupt:
1733
+ proc.terminate()
1734
+
1735
+
1736
+ @main.command(name="tunnel", hidden=True)
1737
+ @click.option("--domain", default=None)
1738
+ @click.option("--ssl", is_flag=True)
1739
+ def tunnel_alias(domain, ssl):
1740
+ """Alias for grok."""
1741
+ grok.callback(domain, ssl)
1742
+
1743
+
1744
+ @main.command(name="share", hidden=True)
1745
+ @click.option("--domain", default=None)
1746
+ @click.option("--ssl", is_flag=True)
1747
+ def share_alias(domain, ssl):
1748
+ """Alias for grok."""
1749
+ grok.callback(domain, ssl)
1750
+
1751
+
1752
+ # ---- Interactive Shell (shell) ---------------------------------------------
1753
+
1754
+ @main.command()
1755
+ def shell():
1756
+ """Open an interactive shell configured with active PHP, Composer, and ndev tools on PATH."""
1757
+ import subprocess
1758
+ paths.ensure_dirs()
1759
+ env = os.environ.copy()
1760
+ env["NDEV_HOME"] = str(paths.NDEV_HOME)
1761
+
1762
+ # Prepend shims and active PHP/MariaDB to PATH
1763
+ shim_path = str(paths.SHIM_DIR)
1764
+ curr_v = php.get_current_version()
1765
+ extra_paths = [shim_path]
1766
+ if curr_v:
1767
+ extra_paths.append(str(paths.version_dir(curr_v)))
1768
+ if (paths.MARIADB_DIR / "bin").exists():
1769
+ extra_paths.append(str(paths.MARIADB_DIR / "bin"))
1770
+ if paths.NGINX_DIR.exists():
1771
+ extra_paths.append(str(paths.NGINX_DIR))
1772
+
1773
+ curr_path = env.get("PATH", "")
1774
+ env["PATH"] = ";".join(extra_paths) + ";" + curr_path
1775
+
1776
+ console.print(f"\n[bold blue]Entering ndev interactive shell (Active PHP: {curr_v or 'none'})...[/bold blue]")
1777
+ console.print("[dim]Type 'exit' to return to normal shell.[/dim]\n")
1778
+
1779
+ ps_prompt_script = f"function prompt {{ '`n(ndev: PHP {curr_v or 'none'}) ' + (Get-Location) + '> ' }}; Write-Host 'ndev developer environment active.' -ForegroundColor Green"
1780
+ try:
1781
+ subprocess.run(["powershell.exe", "-NoLogo", "-NoExit", "-Command", ps_prompt_script], env=env)
1782
+ except Exception:
1783
+ subprocess.run(["cmd.exe"], env=env)
1784
+
1785
+
1786
+ # ---- System Setup (setup) --------------------------------------------------
1787
+
1788
+ @main.command()
1789
+ @click.option("--nginx/--no-nginx", default=True, help="Install Nginx")
1790
+ @click.option("--mariadb/--no-mariadb", default=True, help="Install MariaDB")
1791
+ @click.option("--mkcert/--no-mkcert", default=True, help="Install mkcert for local SSL")
1792
+ @click.option("--ngrok/--no-ngrok", default=True, help="Install ngrok")
1793
+ @click.option("--composer/--no-composer", default=True, help="Install Composer")
1794
+ @click.option("--cacert/--no-cacert", default=True, help="Download Mozilla root CA bundle")
1795
+ @click.option("--nginx-version", default=None)
1796
+ @click.option("--mariadb-version", default=None)
1797
+ @click.option("--mkcert-version", default=None)
1798
+ def setup(nginx, mariadb, mkcert, ngrok, composer, cacert, nginx_version, mariadb_version, mkcert_version):
1799
+ """Download and setup Nginx, MariaDB, mkcert, ngrok, Composer, and CA certificates."""
1800
+ versions = {}
1801
+ if nginx_version:
1802
+ versions["nginx"] = nginx_version
1803
+ if mariadb_version:
1804
+ versions["mariadb"] = mariadb_version
1805
+ if mkcert_version:
1806
+ versions["mkcert"] = mkcert_version
1807
+
1808
+ console.print("\n[bold blue]==================================================================[/bold blue]")
1809
+ console.print("[bold blue] ndev System Environment Setup [/bold blue]")
1810
+ console.print("[bold blue]==================================================================[/bold blue]\n")
1811
+
1812
+ with console.status("[bold green]Downloading and configuring components...[/bold green]"):
1813
+ results = setup_core.run_setup(
1814
+ nginx=nginx,
1815
+ mariadb=mariadb,
1816
+ mkcert=mkcert,
1817
+ ngrok=ngrok,
1818
+ composer=composer,
1819
+ cacert=cacert,
1820
+ versions=versions,
1821
+ )
1822
+
1823
+ for name, path in results.items():
1824
+ console.print(f"[bold green]✓[/bold green] Installed {name:<10} -> {path}")
1825
+
1826
+ shim_on_path = str(paths.SHIM_DIR).lower() in os.environ.get("PATH", "").lower()
1827
+ if not shim_on_path:
1828
+ console.print(f"\n[bold yellow]Important:[/bold yellow] Add [bold cyan]{paths.SHIM_DIR}[/bold cyan] to your system PATH.")
1829
+
1830
+
1831
+ # ---- Component Upgrade (upgrade) --------------------------------------------
1832
+
1833
+ @main.command()
1834
+ @click.argument("component", required=False, default=None)
1835
+ @click.option("--check", is_flag=True, default=False, help="Only check for updates without applying upgrades.")
1836
+ def upgrade(component: Optional[str], check: bool):
1837
+ """Check for and upgrade stack components (Nginx, Mailpit, MariaDB, PMA, mkcert, Composer)."""
1838
+ console.print("\n[bold blue]ndev Stack Component Updates & Upgrades[/bold blue]")
1839
+
1840
+ with console.status("[bold green]Checking component versions...[/bold green]"):
1841
+ if component and component.lower() != "all":
1842
+ all_infos = [upgrade_core.check_all()]
1843
+ # Filter matching component
1844
+ infos = [info for info in upgrade_core.check_all() if info.name == component.lower() or component.lower() in info.name]
1845
+ if not infos:
1846
+ raise click.ClickException(f"Unknown component '{component}'. Available: {', '.join(upgrade_core.COMPONENTS)}")
1847
+ else:
1848
+ infos = upgrade_core.check_all()
1849
+
1850
+ table = Table(title="Stack Components Version Status", show_header=True, header_style="bold cyan")
1851
+ table.add_column("Component", style="bold", min_width=20)
1852
+ table.add_column("Installed Version", min_width=18)
1853
+ table.add_column("Latest Version", min_width=18)
1854
+ table.add_column("Status", min_width=22)
1855
+
1856
+ upgradable = []
1857
+ for info in infos:
1858
+ curr_str = info.current_version or "[dim]Not installed[/dim]"
1859
+ latest_str = info.latest_version or "[dim]Unknown[/dim]"
1860
+ if not info.installed:
1861
+ st_str = "[yellow]Not Installed[/yellow]"
1862
+ elif info.update_available:
1863
+ st_str = "[bold green]Update Available[/bold green]"
1864
+ upgradable.append(info)
1865
+ else:
1866
+ st_str = "[green]Up-to-date[/green]"
1867
+
1868
+ table.add_row(info.display_name, curr_str, latest_str, st_str)
1869
+
1870
+ console.print(table)
1871
+
1872
+ if check:
1873
+ if upgradable:
1874
+ console.print(f"\n[bold green]{len(upgradable)} component(s) can be upgraded.[/bold green] Run `ndev upgrade` to apply.")
1875
+ else:
1876
+ console.print("\n[bold green]All installed components are up-to-date![/bold green]")
1877
+ return
1878
+
1879
+ if not upgradable and not component:
1880
+ console.print("\n[bold green]All installed components are up-to-date![/bold green]")
1881
+ return
1882
+
1883
+ targets = [c.name for c in upgradable] if not component or component.lower() == "all" else [component.lower()]
1884
+ console.print(f"\n[bold blue]Upgrading {len(targets)} component(s): {', '.join(targets)}...[/bold blue]\n")
1885
+
1886
+ for target in targets:
1887
+ with console.status(f"[bold green]Upgrading {target}...[/bold green]"):
1888
+ ok, msg = upgrade_core.upgrade_component(target)
1889
+ if ok:
1890
+ console.print(f"[bold green]✓ {msg}[/bold green]")
1891
+ else:
1892
+ console.print(f"[bold red]✗ {msg}[/bold red]")
1893
+
1894
+ console.print("\n[bold green]Upgrade process complete![/bold green]")
1895
+
1896
+
1897
+ if __name__ == "__main__":
1898
+ sys.exit(main())