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