v0.150.0
  1import os
  2import subprocess
  3import sys
  4from importlib.metadata import entry_points
  5from importlib.util import find_spec
  6
  7import click
  8
  9from plain.cli import register_cli
 10from plain.cli.runtime import common_command
 11from plain.runtime import PLAIN_TEMP_PATH
 12
 13from .alias import AliasManager
 14from .core import ENTRYPOINT_GROUP, DevSupervisor
 15from .services import ServicesSupervisor
 16
 17
 18@common_command
 19@register_cli("dev")
 20@click.group(invoke_without_command=True)
 21@click.pass_context
 22@click.option(
 23    "--port",
 24    "-p",
 25    default="",
 26    type=str,
 27    help=(
 28        "Port to run the web server on. "
 29        "If omitted, tries 8443 and picks the next free port."
 30    ),
 31)
 32@click.option(
 33    "--hostname",
 34    "-h",
 35    default=None,
 36    type=str,
 37    help="Hostname to run the web server on",
 38)
 39@click.option(
 40    "--log-level",
 41    "-l",
 42    default="",
 43    type=click.Choice(["debug", "info", "warning", "error", "critical", ""]),
 44    help="Log level",
 45)
 46@click.option(
 47    "--start",
 48    is_flag=True,
 49    default=False,
 50    help="Start in the background",
 51)
 52@click.option(
 53    "--stop",
 54    is_flag=True,
 55    default=False,
 56    help="Stop the background process",
 57)
 58@click.option(
 59    "--reinstall-ssl",
 60    is_flag=True,
 61    default=False,
 62    help="Reinstall SSL certificates (updates mkcert, reinstalls CA, regenerates certs)",
 63)
 64def cli(
 65    ctx: click.Context,
 66    port: str,
 67    hostname: str | None,
 68    log_level: str,
 69    start: bool,
 70    stop: bool,
 71    reinstall_ssl: bool,
 72) -> None:
 73    """Local development server"""
 74    if ctx.invoked_subcommand:
 75        return
 76
 77    if start and stop:
 78        raise click.UsageError(
 79            "You cannot use both --start and --stop at the same time."
 80        )
 81
 82    os.environ["DEV_SERVICES_AUTO"] = "false"
 83
 84    dev = DevSupervisor()
 85
 86    if stop:
 87        if ServicesSupervisor.running_pid():
 88            ServicesSupervisor().stop_process()
 89            click.secho("Services stopped.", fg="green")
 90
 91        if not dev.running_pid():
 92            click.secho("No development server running.", fg="yellow")
 93            return
 94
 95        dev.stop_process()
 96        click.secho("Development server stopped.", fg="green")
 97        return
 98
 99    if running_pid := dev.running_pid():
100        click.secho(dev.already_running_message(running_pid), fg="yellow")
101        sys.exit(1)
102
103    if start:
104        extra_args = []
105        if port:
106            extra_args.extend(["--port", port])
107        if hostname:
108            extra_args.extend(["--hostname", hostname])
109        if log_level:
110            extra_args.extend(["--log-level", log_level])
111
112        pid = DevSupervisor.spawn_background(*extra_args)
113        click.secho(
114            f"Development server started in the background (pid={pid}).",
115            fg="green",
116        )
117        return
118
119    # Check and prompt for alias setup
120    AliasManager().check_and_prompt()
121
122    dev.setup(
123        port=int(port) if port else None,
124        hostname=hostname,
125        log_level=log_level if log_level else None,
126    )
127    returncode = dev.run(reinstall_ssl=reinstall_ssl)
128    if returncode:
129        sys.exit(returncode)
130
131
132@cli.command()
133@click.option("--start", is_flag=True, help="Start in the background")
134@click.option("--stop", is_flag=True, help="Stop the background process")
135def services(start: bool, stop: bool) -> None:
136    """Start additional development services"""
137
138    if start and stop:
139        raise click.UsageError(
140            "You cannot use both --start and --stop at the same time."
141        )
142
143    if stop:
144        if not ServicesSupervisor.running_pid():
145            click.secho("No services running.", fg="yellow")
146            return
147        ServicesSupervisor().stop_process()
148        click.secho("Services stopped.", fg="green")
149        return
150
151    if running_pid := ServicesSupervisor.running_pid():
152        click.secho(
153            ServicesSupervisor.already_running_message(running_pid), fg="yellow"
154        )
155        sys.exit(1)
156
157    if start:
158        pid = ServicesSupervisor.spawn_background()
159        click.secho(f"Services started in the background (pid={pid}).", fg="green")
160        return
161
162    ServicesSupervisor().run()
163
164
165@cli.command()
166@click.option("--follow", "-f", is_flag=True, help="Follow log output")
167@click.option("--pid", type=int, help="PID to show logs for")
168@click.option("--path", is_flag=True, help="Output log file path")
169@click.option("--services", is_flag=True, help="Show logs for services")
170def logs(follow: bool, pid: int | None, path: bool, services: bool) -> None:
171    """Show recent development logs"""
172
173    if services:
174        log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "services"
175    else:
176        log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "run"
177
178    if pid:
179        log_path = log_dir / f"{pid}.log"
180        if not log_path.exists():
181            click.secho(f"No log found for pid {pid}", fg="red")
182            return
183    else:
184        logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
185        if not logs:
186            click.secho("No logs found", fg="yellow")
187            return
188        log_path = logs[-1]
189
190    if path:
191        click.echo(str(log_path))
192        return
193
194    if follow:
195        subprocess.run(["tail", "-f", str(log_path)])
196    else:
197        with log_path.open() as f:
198            click.echo(f.read())
199
200
201@cli.command()
202@click.option(
203    "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
204)
205@click.argument("entrypoint", required=False)
206def entrypoint(show_list: bool, entrypoint: str | None) -> None:
207    """Run registered development entrypoints"""
208    if not show_list and not entrypoint:
209        raise click.UsageError("Please provide an entrypoint name or use --list")
210
211    for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
212        if show_list:
213            click.echo(entry_point.name)
214        elif entrypoint == entry_point.name:
215            entry_point.load()()
216
217
218if find_spec("plain.postgres"):
219    from .backups.cli import cli as backups_cli
220
221    cli.add_command(backups_cli)