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