ndev-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ndev/__init__.py +8 -0
- ndev/__main__.py +4 -0
- ndev/cli.py +24 -0
- ndev/common/__init__.py +3 -0
- ndev/common/config.py +114 -0
- ndev/common/constants.py +51 -0
- ndev/common/github.py +13 -0
- ndev/common/logger.py +11 -0
- ndev/common/manifest.py +41 -0
- ndev/common/utils.py +96 -0
- ndev/linux/__init__.py +1 -0
- ndev/linux/chroot/manager.py +63 -0
- ndev/linux/chroot/packages.py +91 -0
- ndev/linux/chroot/shell.py +9 -0
- ndev/linux/cli.py +235 -0
- ndev/linux/commands/available.py +38 -0
- ndev/linux/commands/clean.py +25 -0
- ndev/linux/commands/ctl.py +192 -0
- ndev/linux/commands/current.py +11 -0
- ndev/linux/commands/db.py +319 -0
- ndev/linux/commands/doctor.py +56 -0
- ndev/linux/commands/grok.py +75 -0
- ndev/linux/commands/install.py +39 -0
- ndev/linux/commands/list.py +47 -0
- ndev/linux/commands/logs.py +36 -0
- ndev/linux/commands/mailpit.py +82 -0
- ndev/linux/commands/reload.py +26 -0
- ndev/linux/commands/restart.py +34 -0
- ndev/linux/commands/setup.py +113 -0
- ndev/linux/commands/start.py +34 -0
- ndev/linux/commands/status.py +81 -0
- ndev/linux/commands/stop.py +34 -0
- ndev/linux/commands/uninstall.py +69 -0
- ndev/linux/commands/update.py +57 -0
- ndev/linux/commands/upgrade.py +81 -0
- ndev/linux/commands/use.py +108 -0
- ndev/linux/commands/vhost.py +350 -0
- ndev/linux/php/builder.py +183 -0
- ndev/linux/php/downloader.py +58 -0
- ndev/linux/php/extensions.py +146 -0
- ndev/linux/php/installer.py +42 -0
- ndev/linux/php/resolver.py +59 -0
- ndev/linux/php/templates.py +128 -0
- ndev/linux/runtime/fpm.py +117 -0
- ndev/linux/runtime/mailpit.py +244 -0
- ndev/linux/runtime/pma.py +223 -0
- ndev/linux/runtime/process.py +37 -0
- ndev/linux/runtime/sockets.py +16 -0
- ndev/linux/runtime/upgrade.py +431 -0
- ndev/linux/tui.py +1423 -0
- ndev/main.py +52 -0
- ndev/tui.py +23 -0
- ndev/win/__init__.py +1 -0
- ndev/win/cli.py +1898 -0
- ndev/win/commands/__init__.py +0 -0
- ndev/win/core/__init__.py +0 -0
- ndev/win/core/db.py +265 -0
- ndev/win/core/elevate.py +94 -0
- ndev/win/core/ext.py +241 -0
- ndev/win/core/fcgi.py +216 -0
- ndev/win/core/grok.py +55 -0
- ndev/win/core/logs.py +66 -0
- ndev/win/core/mailpit.py +236 -0
- ndev/win/core/mkcert.py +65 -0
- ndev/win/core/paths.py +85 -0
- ndev/win/core/php.py +533 -0
- ndev/win/core/pma.py +190 -0
- ndev/win/core/services.py +349 -0
- ndev/win/core/setup.py +361 -0
- ndev/win/core/upgrade.py +513 -0
- ndev/win/core/vhost.py +289 -0
- ndev/win/templates/vhost.conf.tmpl +33 -0
- ndev/win/templates/vhost_ssl.conf.tmpl +43 -0
- ndev/win/tui.py +1313 -0
- ndev_stack-0.1.0.dist-info/METADATA +553 -0
- ndev_stack-0.1.0.dist-info/RECORD +79 -0
- ndev_stack-0.1.0.dist-info/WHEEL +5 -0
- ndev_stack-0.1.0.dist-info/entry_points.txt +4 -0
- ndev_stack-0.1.0.dist-info/top_level.txt +1 -0
ndev/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())
|