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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import typer
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from ndev.common.logger import logger
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
db_app = typer.Typer(help="Manage MySQL databases and users", invoke_without_command=True)
|
|
13
|
+
|
|
14
|
+
# Shared options for commands
|
|
15
|
+
def execute_sql_mysql(host: str, port: int, user: str, password: Optional[str], sql: str) -> subprocess.CompletedProcess:
|
|
16
|
+
cmd = ["mysql", "-h", host, "-P", str(port), "-u", user, "--batch", "--skip-column-names"]
|
|
17
|
+
if password:
|
|
18
|
+
cmd.append(f"--password={password}")
|
|
19
|
+
return subprocess.run(cmd, input=sql, capture_output=True, text=True)
|
|
20
|
+
|
|
21
|
+
def confirm_destructive(force: bool, database: str, message: str):
|
|
22
|
+
if force:
|
|
23
|
+
return
|
|
24
|
+
console.print(f"\n[bold yellow]WARNING: {message}[/bold yellow]")
|
|
25
|
+
confirm = typer.prompt("Type the database/username to confirm")
|
|
26
|
+
if confirm != database:
|
|
27
|
+
logger.error("Confirmation failed. Aborting.")
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
|
|
30
|
+
def run_wizard():
|
|
31
|
+
console.print("\n========================================")
|
|
32
|
+
console.print(" NDev MySQL Database Manager Wizard")
|
|
33
|
+
console.print("========================================")
|
|
34
|
+
|
|
35
|
+
# 1. Connection Details
|
|
36
|
+
host = typer.prompt("Host", default="localhost")
|
|
37
|
+
port = typer.prompt("Port", default=3306, type=int)
|
|
38
|
+
|
|
39
|
+
admin_user = typer.prompt("Admin username", default="root")
|
|
40
|
+
admin_password = typer.prompt("Admin password", default="", hide_input=True)
|
|
41
|
+
|
|
42
|
+
# 2. Action
|
|
43
|
+
console.print("\nOperation:")
|
|
44
|
+
console.print(" 1) Create Database")
|
|
45
|
+
console.print(" 2) Drop Database")
|
|
46
|
+
console.print(" 3) Export Database (mysqldump)")
|
|
47
|
+
console.print(" 4) Create User")
|
|
48
|
+
console.print(" 5) Drop User")
|
|
49
|
+
op_choice = typer.prompt("Choice [1-5]", default=1, type=int)
|
|
50
|
+
|
|
51
|
+
action_map = {1: "create-db", 2: "drop-db", 3: "export-db", 4: "create-user", 5: "drop-user"}
|
|
52
|
+
action = action_map.get(op_choice, "create-db")
|
|
53
|
+
|
|
54
|
+
target_name = typer.prompt("Database/Username")
|
|
55
|
+
if not target_name:
|
|
56
|
+
logger.error("Target name is required.")
|
|
57
|
+
raise typer.Exit(code=1)
|
|
58
|
+
|
|
59
|
+
# Execute actions
|
|
60
|
+
if action == "create-db":
|
|
61
|
+
owner = typer.prompt("User to grant privileges to (optional)", default="")
|
|
62
|
+
user_host = typer.prompt("Host for mysql user (optional)", default="%") if owner else "%"
|
|
63
|
+
execute_create_db(host, port, admin_user, admin_password, target_name, owner, user_host, "utf8mb4", "utf8mb4_unicode_ci")
|
|
64
|
+
elif action == "drop-db":
|
|
65
|
+
confirm_destructive(False, target_name, f"You are about to DROP database '{target_name}' permanently.")
|
|
66
|
+
execute_drop_db(host, port, admin_user, admin_password, target_name)
|
|
67
|
+
elif action == "export-db":
|
|
68
|
+
output_path = typer.prompt("Output SQL file path (optional, press Enter for default stdout/filename)", default=f"{target_name}.sql")
|
|
69
|
+
execute_export_db(host, port, admin_user, admin_password, target_name, output_path)
|
|
70
|
+
elif action == "create-user":
|
|
71
|
+
new_password = typer.prompt("New password for user", hide_input=True)
|
|
72
|
+
grant_db = typer.prompt("Database to grant privileges to", default=target_name)
|
|
73
|
+
user_host = typer.prompt("Host for mysql user (optional)", default="%")
|
|
74
|
+
execute_create_user(host, port, admin_user, admin_password, target_name, new_password, grant_db, user_host)
|
|
75
|
+
elif action == "drop-user":
|
|
76
|
+
confirm_destructive(False, target_name, f"You are about to DROP user '{target_name}' permanently.")
|
|
77
|
+
execute_drop_user(host, port, admin_user, admin_password, target_name, "%")
|
|
78
|
+
|
|
79
|
+
def execute_create_db(host: str, port: int, user: str, password: Optional[str], database: str, owner: str, user_host: str, charset: str, collation: str):
|
|
80
|
+
logger.info(f"Creating MySQL database '{database}'...")
|
|
81
|
+
if not shutil.which("mysql"):
|
|
82
|
+
logger.error("mysql CLI tool not found.")
|
|
83
|
+
raise typer.Exit(code=1)
|
|
84
|
+
sql = f"CREATE DATABASE IF NOT EXISTS `{database}` CHARACTER SET {charset} COLLATE {collation};"
|
|
85
|
+
res = execute_sql_mysql(host, port, user, password, sql)
|
|
86
|
+
if res.returncode != 0:
|
|
87
|
+
logger.error(f"MySQL Error:\n{res.stderr}")
|
|
88
|
+
raise typer.Exit(code=1)
|
|
89
|
+
|
|
90
|
+
if owner:
|
|
91
|
+
logger.info(f"Granting all privileges on {database} to {owner}...")
|
|
92
|
+
grant_sql = f"GRANT ALL PRIVILEGES ON `{database}`.* TO '{owner}'@'{user_host}'; FLUSH PRIVILEGES;"
|
|
93
|
+
res = execute_sql_mysql(host, port, user, password, grant_sql)
|
|
94
|
+
if res.returncode != 0:
|
|
95
|
+
logger.error(f"MySQL Error:\n{res.stderr}")
|
|
96
|
+
raise typer.Exit(code=1)
|
|
97
|
+
console.print(f"[bold green]Database '{database}' created successfully.[/bold green]")
|
|
98
|
+
|
|
99
|
+
def execute_drop_db(host: str, port: int, user: str, password: Optional[str], database: str):
|
|
100
|
+
logger.info(f"Dropping MySQL database '{database}'...")
|
|
101
|
+
if not shutil.which("mysql"):
|
|
102
|
+
logger.error("mysql CLI tool not found.")
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
sql = f"DROP DATABASE IF EXISTS `{database}`;"
|
|
105
|
+
res = execute_sql_mysql(host, port, user, password, sql)
|
|
106
|
+
if res.returncode != 0:
|
|
107
|
+
logger.error(f"MySQL Error:\n{res.stderr}")
|
|
108
|
+
raise typer.Exit(code=1)
|
|
109
|
+
console.print(f"[bold green]Database '{database}' dropped successfully.[/bold green]")
|
|
110
|
+
|
|
111
|
+
def execute_export_db(
|
|
112
|
+
host: str,
|
|
113
|
+
port: int,
|
|
114
|
+
user: str,
|
|
115
|
+
password: Optional[str],
|
|
116
|
+
database: str,
|
|
117
|
+
output: Optional[str] = None,
|
|
118
|
+
quick: bool = True,
|
|
119
|
+
single_transaction: bool = True,
|
|
120
|
+
routines: bool = True,
|
|
121
|
+
triggers: bool = True
|
|
122
|
+
):
|
|
123
|
+
logger.info(f"Exporting MySQL database '{database}'...")
|
|
124
|
+
if not shutil.which("mysqldump"):
|
|
125
|
+
logger.error("mysqldump CLI tool not found.")
|
|
126
|
+
raise typer.Exit(code=1)
|
|
127
|
+
|
|
128
|
+
cmd = ["mysqldump", "-h", host, "-P", str(port), "-u", user]
|
|
129
|
+
if password:
|
|
130
|
+
cmd.append(f"--password={password}")
|
|
131
|
+
if quick:
|
|
132
|
+
cmd.append("--quick")
|
|
133
|
+
if single_transaction:
|
|
134
|
+
cmd.append("--single-transaction")
|
|
135
|
+
if routines:
|
|
136
|
+
cmd.append("--routines")
|
|
137
|
+
if triggers:
|
|
138
|
+
cmd.append("--triggers")
|
|
139
|
+
cmd.append(database)
|
|
140
|
+
|
|
141
|
+
if output:
|
|
142
|
+
out_path = os.path.abspath(output)
|
|
143
|
+
out_dir = os.path.dirname(out_path)
|
|
144
|
+
if out_dir and not os.path.exists(out_dir):
|
|
145
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
146
|
+
try:
|
|
147
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
148
|
+
res = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.error(f"Failed to open output file '{out_path}': {e}")
|
|
151
|
+
raise typer.Exit(code=1)
|
|
152
|
+
if res.returncode != 0:
|
|
153
|
+
logger.error(f"mysqldump Error:\n{res.stderr}")
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
console.print(f"[bold green]Database '{database}' exported successfully to '{out_path}'.[/bold green]")
|
|
156
|
+
else:
|
|
157
|
+
res = subprocess.run(cmd, capture_output=True, text=True)
|
|
158
|
+
if res.returncode != 0:
|
|
159
|
+
logger.error(f"mysqldump Error:\n{res.stderr}")
|
|
160
|
+
raise typer.Exit(code=1)
|
|
161
|
+
sys.stdout.write(res.stdout)
|
|
162
|
+
|
|
163
|
+
def execute_create_user(host: str, port: int, user: str, password: Optional[str], new_user: str, new_pass: str, grant_db: Optional[str], user_host: str):
|
|
164
|
+
logger.info(f"Creating user '{new_user}' on MySQL...")
|
|
165
|
+
if not shutil.which("mysql"):
|
|
166
|
+
logger.error("mysql CLI tool not found.")
|
|
167
|
+
raise typer.Exit(code=1)
|
|
168
|
+
sql = f"CREATE USER IF NOT EXISTS '{new_user}'@'{user_host}' IDENTIFIED BY '{new_pass}';"
|
|
169
|
+
res = execute_sql_mysql(host, port, user, password, sql)
|
|
170
|
+
if res.returncode != 0:
|
|
171
|
+
logger.error(f"MySQL Error:\n{res.stderr}")
|
|
172
|
+
raise typer.Exit(code=1)
|
|
173
|
+
|
|
174
|
+
if grant_db:
|
|
175
|
+
logger.info(f"Granting all privileges on {grant_db}.* to {new_user}...")
|
|
176
|
+
grant_sql = f"GRANT ALL PRIVILEGES ON `{grant_db}`.* TO '{new_user}'@'{user_host}'; FLUSH PRIVILEGES;"
|
|
177
|
+
res = execute_sql_mysql(host, port, user, password, grant_sql)
|
|
178
|
+
if res.returncode != 0:
|
|
179
|
+
logger.error(f"MySQL Error:\n{res.stderr}")
|
|
180
|
+
raise typer.Exit(code=1)
|
|
181
|
+
console.print(f"[bold green]User '{new_user}' created successfully.[/bold green]")
|
|
182
|
+
|
|
183
|
+
def execute_drop_user(host: str, port: int, user: str, password: Optional[str], drop_user: str, user_host: str):
|
|
184
|
+
logger.info(f"Dropping user '{drop_user}' on MySQL...")
|
|
185
|
+
if not shutil.which("mysql"):
|
|
186
|
+
logger.error("mysql CLI tool not found.")
|
|
187
|
+
raise typer.Exit(code=1)
|
|
188
|
+
sql = f"DROP USER IF EXISTS '{drop_user}'@'{user_host}'; FLUSH PRIVILEGES;"
|
|
189
|
+
res = execute_sql_mysql(host, port, user, password, sql)
|
|
190
|
+
if res.returncode != 0:
|
|
191
|
+
logger.error(f"MySQL Error:\n{res.stderr}")
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
console.print(f"[bold green]User '{drop_user}' dropped successfully.[/bold green]")
|
|
194
|
+
|
|
195
|
+
# CLI command entry points
|
|
196
|
+
@db_app.callback(invoke_without_command=True)
|
|
197
|
+
def db_callback(ctx: typer.Context):
|
|
198
|
+
"""Wizard to manage databases & users if run without subcommands."""
|
|
199
|
+
if ctx.invoked_subcommand is None:
|
|
200
|
+
run_wizard()
|
|
201
|
+
|
|
202
|
+
@db_app.command("create")
|
|
203
|
+
@db_app.command("create-db")
|
|
204
|
+
def create_db_cmd(
|
|
205
|
+
name: str = typer.Argument(None, help="Database name"),
|
|
206
|
+
host: str = typer.Option("localhost", "--host", "-h", help="Database host"),
|
|
207
|
+
port: int = typer.Option(3306, "--port", "-P", help="Database port"),
|
|
208
|
+
user: str = typer.Option("root", "--user", "-u", help="Admin username"),
|
|
209
|
+
password: Optional[str] = typer.Option(None, "--password", "-p", help="Admin password"),
|
|
210
|
+
owner: Optional[str] = typer.Option(None, "--owner", help="MySQL user to grant privileges on"),
|
|
211
|
+
user_host: str = typer.Option("%", "--user-host", help="MySQL user host (default: %)"),
|
|
212
|
+
charset: str = typer.Option("utf8mb4", "--charset", help="MySQL charset"),
|
|
213
|
+
collation: str = typer.Option("utf8mb4_unicode_ci", "--collation", help="MySQL collation")
|
|
214
|
+
):
|
|
215
|
+
"""Create a MySQL database."""
|
|
216
|
+
if not name:
|
|
217
|
+
name = typer.prompt("Database name").strip()
|
|
218
|
+
if not name:
|
|
219
|
+
logger.error("Database name is required.")
|
|
220
|
+
raise typer.Exit(code=1)
|
|
221
|
+
execute_create_db(host, port, user, password, name, owner or "", user_host, charset, collation)
|
|
222
|
+
|
|
223
|
+
@db_app.command("drop")
|
|
224
|
+
@db_app.command("drop-db")
|
|
225
|
+
def drop_db_cmd(
|
|
226
|
+
name: str = typer.Argument(None, help="Database name"),
|
|
227
|
+
host: str = typer.Option("localhost", "--host", "-h", help="Database host"),
|
|
228
|
+
port: int = typer.Option(3306, "--port", "-P", help="Database port"),
|
|
229
|
+
user: str = typer.Option("root", "--user", "-u", help="Admin username"),
|
|
230
|
+
password: Optional[str] = typer.Option(None, "--password", "-p", help="Admin password"),
|
|
231
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt")
|
|
232
|
+
):
|
|
233
|
+
"""Drop a MySQL database."""
|
|
234
|
+
if not name:
|
|
235
|
+
name = typer.prompt("Database name").strip()
|
|
236
|
+
if not name:
|
|
237
|
+
logger.error("Database name is required.")
|
|
238
|
+
raise typer.Exit(code=1)
|
|
239
|
+
confirm_destructive(force, name, f"You are about to DROP database '{name}' permanently.")
|
|
240
|
+
execute_drop_db(host, port, user, password, name)
|
|
241
|
+
|
|
242
|
+
@db_app.command("export")
|
|
243
|
+
@db_app.command("export-db")
|
|
244
|
+
@db_app.command("dump")
|
|
245
|
+
def export_db_cmd(
|
|
246
|
+
name: str = typer.Argument(None, help="Database name"),
|
|
247
|
+
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output SQL file path (prints to stdout if omitted)"),
|
|
248
|
+
host: str = typer.Option("localhost", "--host", "-h", help="Database host"),
|
|
249
|
+
port: int = typer.Option(3306, "--port", "-P", help="Database port"),
|
|
250
|
+
user: str = typer.Option("root", "--user", "-u", help="Admin username"),
|
|
251
|
+
password: Optional[str] = typer.Option(None, "--password", "-p", help="Admin password"),
|
|
252
|
+
quick: bool = typer.Option(True, "--quick/--no-quick", help="Retrieve rows for a table from the server a row at a time"),
|
|
253
|
+
single_transaction: bool = typer.Option(True, "--single-transaction/--no-single-transaction", help="Dump transactional tables in consistent state"),
|
|
254
|
+
routines: bool = typer.Option(True, "--routines/--no-routines", help="Dump stored routines (procedures and functions)"),
|
|
255
|
+
triggers: bool = typer.Option(True, "--triggers/--no-triggers", help="Dump triggers")
|
|
256
|
+
):
|
|
257
|
+
"""Export/dump a MySQL database using mysqldump."""
|
|
258
|
+
if not name:
|
|
259
|
+
name = typer.prompt("Database name").strip()
|
|
260
|
+
if not name:
|
|
261
|
+
logger.error("Database name is required.")
|
|
262
|
+
raise typer.Exit(code=1)
|
|
263
|
+
execute_export_db(
|
|
264
|
+
host=host,
|
|
265
|
+
port=port,
|
|
266
|
+
user=user,
|
|
267
|
+
password=password,
|
|
268
|
+
database=name,
|
|
269
|
+
output=output,
|
|
270
|
+
quick=quick,
|
|
271
|
+
single_transaction=single_transaction,
|
|
272
|
+
routines=routines,
|
|
273
|
+
triggers=triggers
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@db_app.command("create-user")
|
|
277
|
+
def create_user_cmd(
|
|
278
|
+
username: str = typer.Argument(None, help="Username"),
|
|
279
|
+
new_password: str = typer.Option(None, "--new-password", help="Password for the user"),
|
|
280
|
+
host: str = typer.Option("localhost", "--host", "-h", help="Database host"),
|
|
281
|
+
port: int = typer.Option(3306, "--port", "-P", help="Database port"),
|
|
282
|
+
user: str = typer.Option("root", "--user", "-u", help="Admin username"),
|
|
283
|
+
password: Optional[str] = typer.Option(None, "--password", "-p", help="Admin password"),
|
|
284
|
+
grant_db: Optional[str] = typer.Option(None, "--grant-db", help="Database to grant privileges to"),
|
|
285
|
+
user_host: str = typer.Option("%", "--user-host", help="MySQL user host (default: %)")
|
|
286
|
+
):
|
|
287
|
+
"""Create a database user."""
|
|
288
|
+
if not username:
|
|
289
|
+
username = typer.prompt("Username").strip()
|
|
290
|
+
if not username:
|
|
291
|
+
logger.error("Username is required.")
|
|
292
|
+
raise typer.Exit(code=1)
|
|
293
|
+
if not new_password:
|
|
294
|
+
new_password = typer.prompt("Password for the user", hide_input=True).strip()
|
|
295
|
+
if not new_password:
|
|
296
|
+
logger.error("Password is required.")
|
|
297
|
+
raise typer.Exit(code=1)
|
|
298
|
+
db_to_grant = grant_db if grant_db else username
|
|
299
|
+
execute_create_user(host, port, user, password, username, new_password, db_to_grant, user_host)
|
|
300
|
+
|
|
301
|
+
@db_app.command("drop-user")
|
|
302
|
+
def drop_user_cmd(
|
|
303
|
+
username: str = typer.Argument(None, help="Username"),
|
|
304
|
+
host: str = typer.Option("localhost", "--host", "-h", help="Database host"),
|
|
305
|
+
port: int = typer.Option(3306, "--port", "-P", help="Database port"),
|
|
306
|
+
user: str = typer.Option("root", "--user", "-u", help="Admin username"),
|
|
307
|
+
password: Optional[str] = typer.Option(None, "--password", "-p", help="Admin password"),
|
|
308
|
+
user_host: str = typer.Option("%", "--user-host", help="MySQL user host (default: %)"),
|
|
309
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt")
|
|
310
|
+
):
|
|
311
|
+
"""Drop a database user."""
|
|
312
|
+
if not username:
|
|
313
|
+
username = typer.prompt("Username").strip()
|
|
314
|
+
if not username:
|
|
315
|
+
logger.error("Username is required.")
|
|
316
|
+
raise typer.Exit(code=1)
|
|
317
|
+
confirm_destructive(force, username, f"You are about to DROP user '{username}' permanently.")
|
|
318
|
+
execute_drop_user(host, port, user, password, username, user_host)
|
|
319
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from ndev.common.constants import NDEV_DIR, CONFIG_FILE
|
|
7
|
+
from ndev.common.logger import logger
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
def doctor_cmd():
|
|
12
|
+
"""Run diagnostic checks on the host and sandbox environments."""
|
|
13
|
+
table = Table(title="ndev Doctor Diagnostic Report")
|
|
14
|
+
table.add_column("Check", style="bold cyan")
|
|
15
|
+
table.add_column("Status")
|
|
16
|
+
table.add_column("Details")
|
|
17
|
+
|
|
18
|
+
bwrap_path = shutil.which("bwrap")
|
|
19
|
+
if bwrap_path:
|
|
20
|
+
try:
|
|
21
|
+
res = subprocess.run(["bwrap", "--version"], capture_output=True, text=True)
|
|
22
|
+
table.add_row("Bubblewrap (bwrap)", "[green]OK[/green]", f"Found at {bwrap_path} ({res.stdout.strip()})")
|
|
23
|
+
except Exception as e:
|
|
24
|
+
table.add_row("Bubblewrap (bwrap)", "[red]FAILED[/red]", f"Found at {bwrap_path} but failed to run: {e}")
|
|
25
|
+
else:
|
|
26
|
+
table.add_row("Bubblewrap (bwrap)", "[red]MISSING[/red]", "bubblewrap is required to build PHP in a sandbox.")
|
|
27
|
+
|
|
28
|
+
gcc_path = shutil.which("gcc")
|
|
29
|
+
if gcc_path:
|
|
30
|
+
table.add_row("GCC Compiler", "[green]OK[/green]", f"Found at {gcc_path}")
|
|
31
|
+
else:
|
|
32
|
+
table.add_row("GCC Compiler", "[red]MISSING[/red]", "gcc is required to compile PHP.")
|
|
33
|
+
|
|
34
|
+
make_path = shutil.which("make")
|
|
35
|
+
if make_path:
|
|
36
|
+
table.add_row("Make Utility", "[green]OK[/green]", f"Found at {make_path}")
|
|
37
|
+
else:
|
|
38
|
+
table.add_row("Make Utility", "[red]MISSING[/red]", "make is required to build PHP.")
|
|
39
|
+
|
|
40
|
+
pkgconfig_path = shutil.which("pkg-config")
|
|
41
|
+
if pkgconfig_path:
|
|
42
|
+
table.add_row("Pkg-Config Utility", "[green]OK[/green]", f"Found at {pkgconfig_path}")
|
|
43
|
+
else:
|
|
44
|
+
table.add_row("Pkg-Config Utility", "[red]MISSING[/red]", "pkg-config is required to find libraries during compilation.")
|
|
45
|
+
|
|
46
|
+
if NDEV_DIR.exists():
|
|
47
|
+
table.add_row("Layout Directory (~/.ndev)", "[green]OK[/green]", f"Exists at {NDEV_DIR}")
|
|
48
|
+
else:
|
|
49
|
+
table.add_row("Layout Directory (~/.ndev)", "[yellow]WARNING[/yellow]", "Not initialized yet.")
|
|
50
|
+
|
|
51
|
+
if CONFIG_FILE.exists():
|
|
52
|
+
table.add_row("Configuration File", "[green]OK[/green]", f"Found at {CONFIG_FILE}")
|
|
53
|
+
else:
|
|
54
|
+
table.add_row("Configuration File", "[yellow]WARNING[/yellow]", "Not initialized yet.")
|
|
55
|
+
|
|
56
|
+
console.print(table)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import tempfile
|
|
3
|
+
import subprocess
|
|
4
|
+
import shutil
|
|
5
|
+
import typer
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from ndev.common.logger import logger
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
def get_vhosts() -> list[str]:
|
|
13
|
+
nginx_dir = Path("/etc/nginx/sites-enabled")
|
|
14
|
+
domains = []
|
|
15
|
+
if not nginx_dir.exists():
|
|
16
|
+
return domains
|
|
17
|
+
|
|
18
|
+
for file in nginx_dir.glob("*.conf"):
|
|
19
|
+
try:
|
|
20
|
+
content = file.read_text()
|
|
21
|
+
for line in content.splitlines():
|
|
22
|
+
line = line.strip()
|
|
23
|
+
if line.startswith("server_name"):
|
|
24
|
+
parts = line.split()
|
|
25
|
+
for p in parts[1:]:
|
|
26
|
+
p = p.rstrip(";").strip()
|
|
27
|
+
if p and p != "_":
|
|
28
|
+
domains.append(p)
|
|
29
|
+
break
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return domains
|
|
33
|
+
|
|
34
|
+
def grok_cmd():
|
|
35
|
+
"""Proxy local Nginx vhosts over public internet using Ngrok."""
|
|
36
|
+
if not shutil.which("ngrok"):
|
|
37
|
+
logger.error("ngrok is not installed or not in PATH.")
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
domains = get_vhosts()
|
|
41
|
+
if not domains:
|
|
42
|
+
logger.error("No vhosts found in /etc/nginx/sites-enabled.")
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
|
|
45
|
+
console.print("\n[bold]Available VHosts[/bold]")
|
|
46
|
+
console.print("----------------")
|
|
47
|
+
for i, d in enumerate(domains):
|
|
48
|
+
console.print(f" {i + 1}) {d}")
|
|
49
|
+
|
|
50
|
+
console.print("")
|
|
51
|
+
choice = typer.prompt("Select vhost index", type=int)
|
|
52
|
+
if choice < 1 or choice > len(domains):
|
|
53
|
+
logger.error("Invalid selection.")
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
|
|
56
|
+
domain = domains[choice - 1]
|
|
57
|
+
|
|
58
|
+
policy_content = f"""on_http_request:
|
|
59
|
+
- actions:
|
|
60
|
+
- type: add-headers
|
|
61
|
+
config:
|
|
62
|
+
headers:
|
|
63
|
+
Host: {domain}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
|
67
|
+
tmp.write(policy_content)
|
|
68
|
+
policy_file = tmp.name
|
|
69
|
+
|
|
70
|
+
logger.info(f"Selected domain: {domain}")
|
|
71
|
+
logger.info("Starting ngrok...")
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(["ngrok", "http", "80", "--traffic-policy-file", policy_file])
|
|
74
|
+
finally:
|
|
75
|
+
Path(policy_file).unlink(missing_ok=True)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from ndev.linux.php.installer import install_version
|
|
3
|
+
from ndev.common.logger import logger
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
def install_cmd(
|
|
8
|
+
version: str = typer.Argument(None, help="PHP version to install (e.g. 8.4, 8.3.12)"),
|
|
9
|
+
show_logs: Optional[bool] = typer.Option(
|
|
10
|
+
None,
|
|
11
|
+
"--show-logs/--no-show-logs",
|
|
12
|
+
"-s",
|
|
13
|
+
help="Show verbose compilation and installation logs"
|
|
14
|
+
)
|
|
15
|
+
):
|
|
16
|
+
"""Compile and install a PHP version from source."""
|
|
17
|
+
if not version:
|
|
18
|
+
version = typer.prompt("PHP version to install (e.g. 8.4, 8.3.12)").strip()
|
|
19
|
+
if not version:
|
|
20
|
+
logger.error("PHP version is required.")
|
|
21
|
+
raise typer.Exit(code=1)
|
|
22
|
+
|
|
23
|
+
if show_logs is None:
|
|
24
|
+
show_logs = typer.confirm("Show verbose compilation and installation logs?", default=False)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
resolved_version = install_version(version, show_logs=show_logs)
|
|
28
|
+
|
|
29
|
+
# Auto-activate the version if no active version is set
|
|
30
|
+
from ndev.common.constants import CURRENT_LINK
|
|
31
|
+
if not CURRENT_LINK.exists() and not CURRENT_LINK.is_symlink():
|
|
32
|
+
logger.info(f"No active PHP version set. Setting PHP {resolved_version} as active...")
|
|
33
|
+
from ndev.linux.commands.use import use_cmd
|
|
34
|
+
use_cmd(resolved_version)
|
|
35
|
+
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(f"Installation failed: {e}")
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from ndev.common.constants import PHP_DIR, CURRENT_LINK
|
|
6
|
+
from ndev.linux.runtime.fpm import get_fpm_status
|
|
7
|
+
from ndev.common.logger import logger
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
def list_cmd():
|
|
12
|
+
"""List all locally installed PHP versions."""
|
|
13
|
+
if not PHP_DIR.exists():
|
|
14
|
+
logger.info("No PHP versions installed yet. Install one with 'ndev install <version>'.")
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
installed_versions = []
|
|
18
|
+
for path in PHP_DIR.iterdir():
|
|
19
|
+
if path.is_dir():
|
|
20
|
+
installed_versions.append(path.name)
|
|
21
|
+
|
|
22
|
+
if not installed_versions:
|
|
23
|
+
logger.info("No PHP versions installed yet. Install one with 'ndev install <version>'.")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
from packaging.version import parse as parse_version
|
|
27
|
+
installed_versions = sorted(installed_versions, key=parse_version)
|
|
28
|
+
|
|
29
|
+
active_version = None
|
|
30
|
+
if CURRENT_LINK.exists() and CURRENT_LINK.is_symlink():
|
|
31
|
+
active_version = CURRENT_LINK.resolve().name
|
|
32
|
+
|
|
33
|
+
table = Table(title="Installed PHP Versions")
|
|
34
|
+
table.add_column("Version", style="bold cyan")
|
|
35
|
+
table.add_column("Status")
|
|
36
|
+
table.add_column("Active", justify="center")
|
|
37
|
+
|
|
38
|
+
for v in installed_versions:
|
|
39
|
+
status = get_fpm_status(v)
|
|
40
|
+
status_text = "[green]Running[/green]" if status["running"] else "Stopped"
|
|
41
|
+
|
|
42
|
+
is_active = v == active_version
|
|
43
|
+
active_text = "[bold green]* (active)[/bold green]" if is_active else ""
|
|
44
|
+
|
|
45
|
+
table.add_row(v, status_text, active_text)
|
|
46
|
+
|
|
47
|
+
console.print(table)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from ndev.common.constants import LOGS_DIR, CURRENT_LINK
|
|
3
|
+
from ndev.common.logger import logger
|
|
4
|
+
|
|
5
|
+
def logs_cmd(
|
|
6
|
+
version: str = typer.Argument(None, help="PHP version to view logs for (defaults to current active version)"),
|
|
7
|
+
lines: int = typer.Option(50, "--lines", "-n", help="Number of lines to display")
|
|
8
|
+
):
|
|
9
|
+
"""View PHP-FPM logs for a version."""
|
|
10
|
+
from ndev.common.utils import get_version_or_prompt
|
|
11
|
+
if not version:
|
|
12
|
+
version = get_version_or_prompt(version, "PHP version to view logs")
|
|
13
|
+
if not version:
|
|
14
|
+
logger.error("No version specified.")
|
|
15
|
+
raise typer.Exit(code=1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
parts = version.split(".")
|
|
19
|
+
major_minor = f"{parts[0]}{parts[1]}"
|
|
20
|
+
log_file = LOGS_DIR / f"php-fpm-{major_minor}.log"
|
|
21
|
+
|
|
22
|
+
if not log_file.exists():
|
|
23
|
+
logger.error(f"No log file found at {log_file} for version {version}.")
|
|
24
|
+
raise typer.Exit(code=1)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with open(log_file, "r") as f:
|
|
28
|
+
content = f.readlines()
|
|
29
|
+
|
|
30
|
+
last_lines = content[-lines:]
|
|
31
|
+
logger.info(f"Showing last {len(last_lines)} lines of {log_file}:")
|
|
32
|
+
for line in last_lines:
|
|
33
|
+
print(line, end="")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Failed to read log file: {e}")
|
|
36
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mailpit CLI commands for ndev (Linux).
|
|
3
|
+
"""
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ndev.linux.runtime.mailpit import (
|
|
9
|
+
DEFAULT_SMTP_PORT,
|
|
10
|
+
DEFAULT_WEB_PORT,
|
|
11
|
+
get_mailpit_status,
|
|
12
|
+
launch_mailpit,
|
|
13
|
+
restart_mailpit,
|
|
14
|
+
setup_mailpit,
|
|
15
|
+
start_mailpit,
|
|
16
|
+
stop_mailpit,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
mailpit_app = typer.Typer(
|
|
21
|
+
help="Manage Mailpit local email sandbox & SMTP catcher.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mailpit_app.command("install")
|
|
27
|
+
def mailpit_install():
|
|
28
|
+
"""Download and install the prebuilt Mailpit Linux binary."""
|
|
29
|
+
setup_mailpit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mailpit_app.command("start")
|
|
33
|
+
def mailpit_start(
|
|
34
|
+
smtp_port: int = typer.Option(DEFAULT_SMTP_PORT, "--smtp-port", help="SMTP listening port"),
|
|
35
|
+
web_port: int = typer.Option(DEFAULT_WEB_PORT, "--web-port", help="Web UI listening port"),
|
|
36
|
+
):
|
|
37
|
+
"""Start Mailpit background service."""
|
|
38
|
+
start_mailpit(smtp_port=smtp_port, web_port=web_port)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mailpit_app.command("stop")
|
|
42
|
+
def mailpit_stop():
|
|
43
|
+
"""Stop Mailpit background service."""
|
|
44
|
+
stop_mailpit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mailpit_app.command("restart")
|
|
48
|
+
def mailpit_restart(
|
|
49
|
+
smtp_port: int = typer.Option(DEFAULT_SMTP_PORT, "--smtp-port", help="SMTP listening port"),
|
|
50
|
+
web_port: int = typer.Option(DEFAULT_WEB_PORT, "--web-port", help="Web UI listening port"),
|
|
51
|
+
):
|
|
52
|
+
"""Restart Mailpit background service."""
|
|
53
|
+
restart_mailpit(smtp_port=smtp_port, web_port=web_port)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mailpit_app.command("status")
|
|
57
|
+
def mailpit_status():
|
|
58
|
+
"""Show status of Mailpit service."""
|
|
59
|
+
st = get_mailpit_status()
|
|
60
|
+
table = Table(title="Mailpit Service Status")
|
|
61
|
+
table.add_column("Property", style="bold cyan")
|
|
62
|
+
table.add_column("Value")
|
|
63
|
+
|
|
64
|
+
status_text = "[bold green]Running[/bold green]" if st["running"] else "[bold red]Stopped[/bold red]"
|
|
65
|
+
table.add_row("Service", "Mailpit")
|
|
66
|
+
table.add_row("Status", status_text)
|
|
67
|
+
table.add_row("PID", str(st["pid"]) if st["pid"] else "N/A")
|
|
68
|
+
table.add_row("SMTP Server", f"127.0.0.1:{st['smtp_port']}")
|
|
69
|
+
table.add_row("Web UI URL", st["url"] if st["url"] else f"http://127.0.0.1:{st['web_port']} (Stopped)")
|
|
70
|
+
console.print(table)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mailpit_app.command("launch")
|
|
74
|
+
def mailpit_launch_cmd():
|
|
75
|
+
"""Open Mailpit web UI in default browser (starts service if stopped)."""
|
|
76
|
+
launch_mailpit()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@mailpit_app.command("open", hidden=True)
|
|
80
|
+
def mailpit_open_alias():
|
|
81
|
+
"""Alias for launch."""
|
|
82
|
+
launch_mailpit()
|