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