aegra-cli 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.
- aegra_cli/__init__.py +3 -0
- aegra_cli/cli.py +485 -0
- aegra_cli/commands/__init__.py +6 -0
- aegra_cli/commands/db.py +217 -0
- aegra_cli/commands/init.py +272 -0
- aegra_cli/utils/__init__.py +1 -0
- aegra_cli/utils/docker.py +339 -0
- aegra_cli-0.1.0.dist-info/METADATA +360 -0
- aegra_cli-0.1.0.dist-info/RECORD +11 -0
- aegra_cli-0.1.0.dist-info/WHEEL +4 -0
- aegra_cli-0.1.0.dist-info/entry_points.txt +2 -0
aegra_cli/__init__.py
ADDED
aegra_cli/cli.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Aegra CLI - Command-line interface for managing self-hosted agent deployments."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from aegra_cli import __version__
|
|
14
|
+
from aegra_cli.commands import db, init
|
|
15
|
+
from aegra_cli.utils.docker import ensure_postgres_running
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
# Attempt to get aegra-api version
|
|
20
|
+
try:
|
|
21
|
+
from aegra_api import __version__ as api_version
|
|
22
|
+
except ImportError:
|
|
23
|
+
api_version = "not installed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
@click.version_option(version=__version__, prog_name="aegra-cli")
|
|
28
|
+
def cli():
|
|
29
|
+
"""Aegra CLI - Manage your self-hosted agent deployments.
|
|
30
|
+
|
|
31
|
+
Aegra is an open-source, self-hosted alternative to LangGraph Platform.
|
|
32
|
+
Use this CLI to run development servers, manage Docker services, and more.
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cli.command()
|
|
38
|
+
def version():
|
|
39
|
+
"""Show version information for aegra-cli and aegra-api."""
|
|
40
|
+
table = Table(title="Aegra Version Information", show_header=True, header_style="bold cyan")
|
|
41
|
+
table.add_column("Component", style="bold")
|
|
42
|
+
table.add_column("Version", style="green")
|
|
43
|
+
|
|
44
|
+
table.add_row("aegra-cli", __version__)
|
|
45
|
+
table.add_row("aegra-api", api_version)
|
|
46
|
+
|
|
47
|
+
console.print()
|
|
48
|
+
console.print(table)
|
|
49
|
+
console.print()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_env_file(env_file: Path | None) -> Path | None:
|
|
53
|
+
"""Load environment variables from a .env file.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
env_file: Path to .env file, or None to use default (.env in cwd)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Path to the loaded .env file, or None if not found
|
|
60
|
+
"""
|
|
61
|
+
import os
|
|
62
|
+
|
|
63
|
+
# Determine which file to load
|
|
64
|
+
if env_file is not None:
|
|
65
|
+
target = env_file
|
|
66
|
+
else:
|
|
67
|
+
# Default: look for .env in current directory
|
|
68
|
+
target = Path.cwd() / ".env"
|
|
69
|
+
|
|
70
|
+
if not target.exists():
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
# Load the .env file into environment
|
|
74
|
+
# Simple parser - handles KEY=value format
|
|
75
|
+
with open(target, encoding="utf-8") as f:
|
|
76
|
+
for line in f:
|
|
77
|
+
line = line.strip()
|
|
78
|
+
# Skip empty lines and comments
|
|
79
|
+
if not line or line.startswith("#"):
|
|
80
|
+
continue
|
|
81
|
+
# Parse KEY=value (handle = in value)
|
|
82
|
+
if "=" in line:
|
|
83
|
+
key, _, value = line.partition("=")
|
|
84
|
+
key = key.strip()
|
|
85
|
+
value = value.strip()
|
|
86
|
+
# Remove surrounding quotes if present
|
|
87
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
88
|
+
value.startswith("'") and value.endswith("'")
|
|
89
|
+
):
|
|
90
|
+
value = value[1:-1]
|
|
91
|
+
# Only set if not already in environment (env vars take precedence)
|
|
92
|
+
if key and key not in os.environ:
|
|
93
|
+
os.environ[key] = value
|
|
94
|
+
|
|
95
|
+
return target
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_config_file() -> Path | None:
|
|
99
|
+
"""Find aegra.json or langgraph.json in current directory.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Path to config file if found, None otherwise
|
|
103
|
+
"""
|
|
104
|
+
# Check for aegra.json first
|
|
105
|
+
aegra_config = Path.cwd() / "aegra.json"
|
|
106
|
+
if aegra_config.exists():
|
|
107
|
+
return aegra_config
|
|
108
|
+
|
|
109
|
+
# Fallback to langgraph.json
|
|
110
|
+
langgraph_config = Path.cwd() / "langgraph.json"
|
|
111
|
+
if langgraph_config.exists():
|
|
112
|
+
return langgraph_config
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@cli.command()
|
|
118
|
+
@click.option(
|
|
119
|
+
"--host",
|
|
120
|
+
default="127.0.0.1",
|
|
121
|
+
help="Host to bind the server to.",
|
|
122
|
+
show_default=True,
|
|
123
|
+
)
|
|
124
|
+
@click.option(
|
|
125
|
+
"--port",
|
|
126
|
+
default=8000,
|
|
127
|
+
type=int,
|
|
128
|
+
help="Port to bind the server to.",
|
|
129
|
+
show_default=True,
|
|
130
|
+
)
|
|
131
|
+
@click.option(
|
|
132
|
+
"--app",
|
|
133
|
+
default="aegra_api.main:app",
|
|
134
|
+
help="Application import path.",
|
|
135
|
+
show_default=True,
|
|
136
|
+
)
|
|
137
|
+
@click.option(
|
|
138
|
+
"--config",
|
|
139
|
+
"-c",
|
|
140
|
+
"config_file",
|
|
141
|
+
default=None,
|
|
142
|
+
type=click.Path(exists=True, path_type=Path),
|
|
143
|
+
help="Path to aegra.json config file (auto-discovered if not specified).",
|
|
144
|
+
)
|
|
145
|
+
@click.option(
|
|
146
|
+
"--env-file",
|
|
147
|
+
"-e",
|
|
148
|
+
"env_file",
|
|
149
|
+
default=None,
|
|
150
|
+
type=click.Path(exists=True, path_type=Path),
|
|
151
|
+
help="Path to .env file (default: .env in project directory).",
|
|
152
|
+
)
|
|
153
|
+
@click.option(
|
|
154
|
+
"--no-db-check",
|
|
155
|
+
is_flag=True,
|
|
156
|
+
default=False,
|
|
157
|
+
help="Skip automatic PostgreSQL/Docker check.",
|
|
158
|
+
)
|
|
159
|
+
@click.option(
|
|
160
|
+
"--file",
|
|
161
|
+
"-f",
|
|
162
|
+
"compose_file",
|
|
163
|
+
default=None,
|
|
164
|
+
type=click.Path(exists=True, path_type=Path),
|
|
165
|
+
help="Path to docker-compose.yml file for PostgreSQL.",
|
|
166
|
+
)
|
|
167
|
+
def dev(
|
|
168
|
+
host: str,
|
|
169
|
+
port: int,
|
|
170
|
+
app: str,
|
|
171
|
+
config_file: Path | None,
|
|
172
|
+
env_file: Path | None,
|
|
173
|
+
no_db_check: bool,
|
|
174
|
+
compose_file: Path | None,
|
|
175
|
+
):
|
|
176
|
+
"""Run the development server with hot reload.
|
|
177
|
+
|
|
178
|
+
Starts uvicorn with --reload flag for development.
|
|
179
|
+
The server will automatically restart when code changes are detected.
|
|
180
|
+
|
|
181
|
+
Aegra auto-discovers aegra.json by walking up the directory tree, so you
|
|
182
|
+
can run 'aegra dev' from any subdirectory of your project.
|
|
183
|
+
|
|
184
|
+
By default, Aegra will check if Docker is running and start PostgreSQL
|
|
185
|
+
automatically if needed. Use --no-db-check to skip this behavior.
|
|
186
|
+
|
|
187
|
+
Examples:
|
|
188
|
+
|
|
189
|
+
aegra dev # Auto-discover config, start server
|
|
190
|
+
|
|
191
|
+
aegra dev -c /path/to/aegra.json # Use specific config file
|
|
192
|
+
|
|
193
|
+
aegra dev -e /path/to/.env # Use specific .env file
|
|
194
|
+
|
|
195
|
+
aegra dev --no-db-check # Start without database check
|
|
196
|
+
"""
|
|
197
|
+
import os
|
|
198
|
+
|
|
199
|
+
# Discover or validate config file
|
|
200
|
+
if config_file is not None:
|
|
201
|
+
# User specified a config file explicitly
|
|
202
|
+
resolved_config = config_file.resolve()
|
|
203
|
+
else:
|
|
204
|
+
# Auto-discover config file by walking up directory tree
|
|
205
|
+
resolved_config = find_config_file()
|
|
206
|
+
|
|
207
|
+
if resolved_config is None:
|
|
208
|
+
console.print(
|
|
209
|
+
"[bold red]Error:[/bold red] Could not find aegra.json or langgraph.json.\n"
|
|
210
|
+
"Run [cyan]aegra init[/cyan] to create a new project, or specify "
|
|
211
|
+
"[cyan]--config[/cyan] to point to your config file."
|
|
212
|
+
)
|
|
213
|
+
sys.exit(1)
|
|
214
|
+
|
|
215
|
+
console.print(f"[dim]Using config: {resolved_config}[/dim]")
|
|
216
|
+
|
|
217
|
+
# Set AEGRA_CONFIG env var so aegra-api resolves paths relative to config location
|
|
218
|
+
os.environ["AEGRA_CONFIG"] = str(resolved_config)
|
|
219
|
+
|
|
220
|
+
# Load environment variables from .env file
|
|
221
|
+
# Default: look in config file's directory first, then cwd
|
|
222
|
+
if env_file is None:
|
|
223
|
+
# Try config directory first
|
|
224
|
+
config_dir_env = resolved_config.parent / ".env"
|
|
225
|
+
if config_dir_env.exists():
|
|
226
|
+
env_file = config_dir_env
|
|
227
|
+
|
|
228
|
+
loaded_env = load_env_file(env_file)
|
|
229
|
+
if loaded_env:
|
|
230
|
+
console.print(f"[dim]Loaded environment from: {loaded_env}[/dim]")
|
|
231
|
+
elif env_file is not None:
|
|
232
|
+
# User specified a file but it doesn't exist (shouldn't happen due to click validation)
|
|
233
|
+
console.print(f"[yellow]Warning: .env file not found: {env_file}[/yellow]")
|
|
234
|
+
|
|
235
|
+
# Check and start PostgreSQL unless disabled
|
|
236
|
+
if not no_db_check:
|
|
237
|
+
console.print()
|
|
238
|
+
if not ensure_postgres_running(compose_file):
|
|
239
|
+
console.print(
|
|
240
|
+
"\n[bold red]Cannot start server without PostgreSQL.[/bold red]\n"
|
|
241
|
+
"[dim]Use --no-db-check to skip this check.[/dim]"
|
|
242
|
+
)
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
console.print()
|
|
245
|
+
|
|
246
|
+
# Build info panel content
|
|
247
|
+
info_lines = [
|
|
248
|
+
"[bold green]Starting Aegra development server[/bold green]\n",
|
|
249
|
+
f"[cyan]Host:[/cyan] {host}",
|
|
250
|
+
f"[cyan]Port:[/cyan] {port}",
|
|
251
|
+
f"[cyan]App:[/cyan] {app}",
|
|
252
|
+
f"[cyan]Config:[/cyan] {resolved_config}",
|
|
253
|
+
]
|
|
254
|
+
if loaded_env:
|
|
255
|
+
info_lines.append(f"[cyan]Env:[/cyan] {loaded_env}")
|
|
256
|
+
info_lines.append("\n[dim]Press Ctrl+C to stop the server[/dim]")
|
|
257
|
+
|
|
258
|
+
console.print(
|
|
259
|
+
Panel(
|
|
260
|
+
"\n".join(info_lines),
|
|
261
|
+
title="[bold]Aegra Dev Server[/bold]",
|
|
262
|
+
border_style="green",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
cmd = [
|
|
267
|
+
sys.executable,
|
|
268
|
+
"-m",
|
|
269
|
+
"uvicorn",
|
|
270
|
+
app,
|
|
271
|
+
"--host",
|
|
272
|
+
host,
|
|
273
|
+
"--port",
|
|
274
|
+
str(port),
|
|
275
|
+
"--reload",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
process = None
|
|
279
|
+
try:
|
|
280
|
+
# Use Popen for better signal handling across platforms
|
|
281
|
+
process = subprocess.Popen(cmd)
|
|
282
|
+
|
|
283
|
+
# Set up signal handler to forward signals to child process
|
|
284
|
+
def signal_handler(signum, frame):
|
|
285
|
+
if process and process.poll() is None: # Process still running
|
|
286
|
+
process.terminate()
|
|
287
|
+
try:
|
|
288
|
+
process.wait(timeout=5)
|
|
289
|
+
except subprocess.TimeoutExpired:
|
|
290
|
+
process.kill()
|
|
291
|
+
console.print("\n[yellow]Server stopped by user.[/yellow]")
|
|
292
|
+
sys.exit(0)
|
|
293
|
+
|
|
294
|
+
# Register signal handlers (SIGTERM not available on Windows)
|
|
295
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
296
|
+
if hasattr(signal, "SIGTERM"):
|
|
297
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
298
|
+
|
|
299
|
+
# Wait for the process to complete
|
|
300
|
+
returncode = process.wait()
|
|
301
|
+
sys.exit(returncode)
|
|
302
|
+
|
|
303
|
+
except FileNotFoundError:
|
|
304
|
+
console.print(
|
|
305
|
+
"[bold red]Error:[/bold red] uvicorn is not installed.\n"
|
|
306
|
+
"Install it with: [cyan]pip install uvicorn[/cyan]"
|
|
307
|
+
)
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
except KeyboardInterrupt:
|
|
310
|
+
# Fallback handler if signal handler didn't catch it
|
|
311
|
+
if process and process.poll() is None:
|
|
312
|
+
process.terminate()
|
|
313
|
+
try:
|
|
314
|
+
process.wait(timeout=5)
|
|
315
|
+
except subprocess.TimeoutExpired:
|
|
316
|
+
process.kill()
|
|
317
|
+
console.print("\n[yellow]Server stopped by user.[/yellow]")
|
|
318
|
+
sys.exit(0)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@cli.command()
|
|
322
|
+
@click.option(
|
|
323
|
+
"--file",
|
|
324
|
+
"-f",
|
|
325
|
+
"compose_file",
|
|
326
|
+
default=None,
|
|
327
|
+
type=click.Path(exists=True, path_type=Path),
|
|
328
|
+
help="Path to docker-compose.yml file.",
|
|
329
|
+
)
|
|
330
|
+
@click.option(
|
|
331
|
+
"--build",
|
|
332
|
+
is_flag=True,
|
|
333
|
+
default=False,
|
|
334
|
+
help="Build images before starting containers.",
|
|
335
|
+
)
|
|
336
|
+
@click.argument("services", nargs=-1)
|
|
337
|
+
def up(compose_file: Path | None, build: bool, services: tuple[str, ...]):
|
|
338
|
+
"""Start services with Docker Compose.
|
|
339
|
+
|
|
340
|
+
Runs 'docker compose up -d' to start services in detached mode.
|
|
341
|
+
Optionally specify specific services to start.
|
|
342
|
+
|
|
343
|
+
Examples:
|
|
344
|
+
|
|
345
|
+
aegra up # Start all services
|
|
346
|
+
|
|
347
|
+
aegra up postgres # Start only postgres
|
|
348
|
+
|
|
349
|
+
aegra up --build # Build and start all services
|
|
350
|
+
|
|
351
|
+
aegra up -f ./docker-compose.prod.yml
|
|
352
|
+
"""
|
|
353
|
+
console.print(
|
|
354
|
+
Panel(
|
|
355
|
+
"[bold green]Starting Aegra services with Docker Compose[/bold green]",
|
|
356
|
+
title="[bold]Aegra Up[/bold]",
|
|
357
|
+
border_style="green",
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
cmd = ["docker", "compose"]
|
|
362
|
+
|
|
363
|
+
if compose_file:
|
|
364
|
+
cmd.extend(["-f", str(compose_file)])
|
|
365
|
+
|
|
366
|
+
cmd.append("up")
|
|
367
|
+
cmd.append("-d")
|
|
368
|
+
|
|
369
|
+
if build:
|
|
370
|
+
cmd.append("--build")
|
|
371
|
+
|
|
372
|
+
if services:
|
|
373
|
+
cmd.extend(services)
|
|
374
|
+
console.print(f"[cyan]Services:[/cyan] {', '.join(services)}")
|
|
375
|
+
else:
|
|
376
|
+
console.print("[cyan]Services:[/cyan] all")
|
|
377
|
+
|
|
378
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
result = subprocess.run(cmd, check=False)
|
|
382
|
+
if result.returncode == 0:
|
|
383
|
+
console.print("\n[bold green]Services started successfully![/bold green]")
|
|
384
|
+
console.print("[dim]Use 'aegra down' to stop services[/dim]")
|
|
385
|
+
else:
|
|
386
|
+
console.print(
|
|
387
|
+
f"\n[bold red]Error:[/bold red] Docker Compose exited with code {result.returncode}"
|
|
388
|
+
)
|
|
389
|
+
sys.exit(result.returncode)
|
|
390
|
+
except FileNotFoundError:
|
|
391
|
+
console.print(
|
|
392
|
+
"[bold red]Error:[/bold red] docker is not installed or not in PATH.\n"
|
|
393
|
+
"Please install Docker Desktop or Docker Engine."
|
|
394
|
+
)
|
|
395
|
+
sys.exit(1)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@cli.command()
|
|
399
|
+
@click.option(
|
|
400
|
+
"--file",
|
|
401
|
+
"-f",
|
|
402
|
+
"compose_file",
|
|
403
|
+
default=None,
|
|
404
|
+
type=click.Path(exists=True, path_type=Path),
|
|
405
|
+
help="Path to docker-compose.yml file.",
|
|
406
|
+
)
|
|
407
|
+
@click.option(
|
|
408
|
+
"--volumes",
|
|
409
|
+
"-v",
|
|
410
|
+
is_flag=True,
|
|
411
|
+
default=False,
|
|
412
|
+
help="Remove named volumes declared in the compose file.",
|
|
413
|
+
)
|
|
414
|
+
@click.argument("services", nargs=-1)
|
|
415
|
+
def down(compose_file: Path | None, volumes: bool, services: tuple[str, ...]):
|
|
416
|
+
"""Stop services with Docker Compose.
|
|
417
|
+
|
|
418
|
+
Runs 'docker compose down' to stop and remove containers.
|
|
419
|
+
|
|
420
|
+
Examples:
|
|
421
|
+
|
|
422
|
+
aegra down # Stop all services
|
|
423
|
+
|
|
424
|
+
aegra down postgres # Stop only postgres
|
|
425
|
+
|
|
426
|
+
aegra down -v # Stop and remove volumes
|
|
427
|
+
|
|
428
|
+
aegra down -f ./docker-compose.prod.yml
|
|
429
|
+
"""
|
|
430
|
+
console.print(
|
|
431
|
+
Panel(
|
|
432
|
+
"[bold yellow]Stopping Aegra services with Docker Compose[/bold yellow]",
|
|
433
|
+
title="[bold]Aegra Down[/bold]",
|
|
434
|
+
border_style="yellow",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
cmd = ["docker", "compose"]
|
|
439
|
+
|
|
440
|
+
if compose_file:
|
|
441
|
+
cmd.extend(["-f", str(compose_file)])
|
|
442
|
+
|
|
443
|
+
cmd.append("down")
|
|
444
|
+
|
|
445
|
+
if volumes:
|
|
446
|
+
cmd.append("-v")
|
|
447
|
+
console.print("[yellow]Warning:[/yellow] Removing volumes - data will be lost!")
|
|
448
|
+
|
|
449
|
+
if services:
|
|
450
|
+
cmd.extend(services)
|
|
451
|
+
console.print(f"[cyan]Services:[/cyan] {', '.join(services)}")
|
|
452
|
+
else:
|
|
453
|
+
console.print("[cyan]Services:[/cyan] all")
|
|
454
|
+
|
|
455
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
result = subprocess.run(cmd, check=False)
|
|
459
|
+
if result.returncode == 0:
|
|
460
|
+
console.print("\n[bold green]Services stopped successfully![/bold green]")
|
|
461
|
+
else:
|
|
462
|
+
console.print(
|
|
463
|
+
f"\n[bold red]Error:[/bold red] Docker Compose exited with code {result.returncode}"
|
|
464
|
+
)
|
|
465
|
+
sys.exit(result.returncode)
|
|
466
|
+
except FileNotFoundError:
|
|
467
|
+
console.print(
|
|
468
|
+
"[bold red]Error:[/bold red] docker is not installed or not in PATH.\n"
|
|
469
|
+
"Please install Docker Desktop or Docker Engine."
|
|
470
|
+
)
|
|
471
|
+
sys.exit(1)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# Register command groups and commands from the commands package
|
|
475
|
+
cli.add_command(db)
|
|
476
|
+
cli.add_command(init)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def main():
|
|
480
|
+
"""Entry point for the CLI."""
|
|
481
|
+
cli()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
main()
|
aegra_cli/commands/db.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Database migration commands for Aegra."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def db():
|
|
15
|
+
"""Database migration commands.
|
|
16
|
+
|
|
17
|
+
Manage database migrations using Alembic.
|
|
18
|
+
These commands are wrappers around common Alembic operations.
|
|
19
|
+
"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@db.command()
|
|
24
|
+
def upgrade():
|
|
25
|
+
"""Apply all pending migrations.
|
|
26
|
+
|
|
27
|
+
Runs 'alembic upgrade head' to apply all pending migrations
|
|
28
|
+
and bring the database schema up to date.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
|
|
32
|
+
aegra db upgrade
|
|
33
|
+
"""
|
|
34
|
+
console.print(
|
|
35
|
+
Panel(
|
|
36
|
+
"[bold green]Upgrading database to latest migration[/bold green]",
|
|
37
|
+
title="[bold]Database Upgrade[/bold]",
|
|
38
|
+
border_style="green",
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
cmd = [sys.executable, "-m", "alembic", "upgrade", "head"]
|
|
43
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(cmd, check=False)
|
|
47
|
+
if result.returncode == 0:
|
|
48
|
+
console.print("\n[bold green]Database upgraded successfully![/bold green]")
|
|
49
|
+
else:
|
|
50
|
+
console.print(
|
|
51
|
+
f"\n[bold red]Error:[/bold red] Alembic upgrade failed "
|
|
52
|
+
f"with exit code {result.returncode}"
|
|
53
|
+
)
|
|
54
|
+
sys.exit(result.returncode)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
console.print(
|
|
57
|
+
"[bold red]Error:[/bold red] Alembic is not installed.\n"
|
|
58
|
+
"Install it with: [cyan]pip install alembic[/cyan]"
|
|
59
|
+
)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@db.command()
|
|
67
|
+
@click.argument("revision", default="-1")
|
|
68
|
+
def downgrade(revision: str):
|
|
69
|
+
"""Downgrade database to a previous revision.
|
|
70
|
+
|
|
71
|
+
Runs 'alembic downgrade' with the specified revision.
|
|
72
|
+
Use '-1' to downgrade by one revision, or specify a revision hash.
|
|
73
|
+
|
|
74
|
+
Arguments:
|
|
75
|
+
|
|
76
|
+
REVISION: Target revision (default: -1 for one step back)
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
|
|
80
|
+
aegra db downgrade # Downgrade by one revision
|
|
81
|
+
|
|
82
|
+
aegra db downgrade -2 # Downgrade by two revisions
|
|
83
|
+
|
|
84
|
+
aegra db downgrade base # Downgrade to initial state
|
|
85
|
+
|
|
86
|
+
aegra db downgrade abc123 # Downgrade to specific revision
|
|
87
|
+
"""
|
|
88
|
+
console.print(
|
|
89
|
+
Panel(
|
|
90
|
+
f"[bold yellow]Downgrading database to revision: {revision}[/bold yellow]",
|
|
91
|
+
title="[bold]Database Downgrade[/bold]",
|
|
92
|
+
border_style="yellow",
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if revision == "base":
|
|
97
|
+
console.print("[yellow]Warning:[/yellow] Downgrading to 'base' will remove all migrations!")
|
|
98
|
+
|
|
99
|
+
cmd = [sys.executable, "-m", "alembic", "downgrade", revision]
|
|
100
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(cmd, check=False)
|
|
104
|
+
if result.returncode == 0:
|
|
105
|
+
console.print("\n[bold green]Database downgraded successfully![/bold green]")
|
|
106
|
+
else:
|
|
107
|
+
console.print(
|
|
108
|
+
f"\n[bold red]Error:[/bold red] Alembic downgrade failed "
|
|
109
|
+
f"with exit code {result.returncode}"
|
|
110
|
+
)
|
|
111
|
+
sys.exit(result.returncode)
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
console.print(
|
|
114
|
+
"[bold red]Error:[/bold red] Alembic is not installed.\n"
|
|
115
|
+
"Install it with: [cyan]pip install alembic[/cyan]"
|
|
116
|
+
)
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@db.command()
|
|
124
|
+
def current():
|
|
125
|
+
"""Show current migration version.
|
|
126
|
+
|
|
127
|
+
Displays the current revision that the database is at.
|
|
128
|
+
Useful for checking which migrations have been applied.
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
|
|
132
|
+
aegra db current
|
|
133
|
+
"""
|
|
134
|
+
console.print(
|
|
135
|
+
Panel(
|
|
136
|
+
"[bold cyan]Checking current database revision[/bold cyan]",
|
|
137
|
+
title="[bold]Database Current[/bold]",
|
|
138
|
+
border_style="cyan",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
cmd = [sys.executable, "-m", "alembic", "current"]
|
|
143
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
result = subprocess.run(cmd, check=False)
|
|
147
|
+
if result.returncode == 0:
|
|
148
|
+
console.print("\n[bold green]Current revision displayed above.[/bold green]")
|
|
149
|
+
else:
|
|
150
|
+
console.print(
|
|
151
|
+
f"\n[bold red]Error:[/bold red] Alembic current failed "
|
|
152
|
+
f"with exit code {result.returncode}"
|
|
153
|
+
)
|
|
154
|
+
sys.exit(result.returncode)
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
console.print(
|
|
157
|
+
"[bold red]Error:[/bold red] Alembic is not installed.\n"
|
|
158
|
+
"Install it with: [cyan]pip install alembic[/cyan]"
|
|
159
|
+
)
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
except KeyboardInterrupt:
|
|
162
|
+
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@db.command()
|
|
167
|
+
@click.option(
|
|
168
|
+
"--verbose",
|
|
169
|
+
"-v",
|
|
170
|
+
is_flag=True,
|
|
171
|
+
default=False,
|
|
172
|
+
help="Show detailed migration information.",
|
|
173
|
+
)
|
|
174
|
+
def history(verbose: bool):
|
|
175
|
+
"""Show migration history.
|
|
176
|
+
|
|
177
|
+
Displays the list of migrations in the Alembic history.
|
|
178
|
+
Use --verbose for more detailed information.
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
|
|
182
|
+
aegra db history # Show migration history
|
|
183
|
+
|
|
184
|
+
aegra db history --verbose # Show detailed history
|
|
185
|
+
"""
|
|
186
|
+
console.print(
|
|
187
|
+
Panel(
|
|
188
|
+
"[bold cyan]Displaying migration history[/bold cyan]",
|
|
189
|
+
title="[bold]Database History[/bold]",
|
|
190
|
+
border_style="cyan",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
cmd = [sys.executable, "-m", "alembic", "history"]
|
|
195
|
+
if verbose:
|
|
196
|
+
cmd.append("--verbose")
|
|
197
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]\n")
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
result = subprocess.run(cmd, check=False)
|
|
201
|
+
if result.returncode == 0:
|
|
202
|
+
console.print("\n[bold green]Migration history displayed above.[/bold green]")
|
|
203
|
+
else:
|
|
204
|
+
console.print(
|
|
205
|
+
f"\n[bold red]Error:[/bold red] Alembic history failed "
|
|
206
|
+
f"with exit code {result.returncode}"
|
|
207
|
+
)
|
|
208
|
+
sys.exit(result.returncode)
|
|
209
|
+
except FileNotFoundError:
|
|
210
|
+
console.print(
|
|
211
|
+
"[bold red]Error:[/bold red] Alembic is not installed.\n"
|
|
212
|
+
"Install it with: [cyan]pip install alembic[/cyan]"
|
|
213
|
+
)
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
217
|
+
sys.exit(1)
|