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()()