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)