1import os
2import subprocess
3import sys
4import time
5from importlib.metadata import entry_points
6from typing import Any
7
8import click
9
10from plain.cli import register_cli
11from plain.cli.runtime import common_command
12from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
13
14from .alias import AliasManager
15from .core import ENTRYPOINT_GROUP, DevProcess
16from .services import ServicesProcess
17
18
19class DevGroup(click.Group):
20 """Custom group that ensures *services* are running on CLI startup."""
21
22 def __init__(self, *args: Any, **kwargs: Any) -> None:
23 super().__init__(*args, **kwargs)
24 self._auto_start_services()
25
26 @staticmethod
27 def _auto_start_services() -> None:
28 """Start dev *services* in the background if not already running."""
29
30 # Check if we're in CI and auto-start is not explicitly enabled
31 if os.environ.get("CI") and os.environ.get("PLAIN_DEV_SERVICES_AUTO") is None:
32 return
33
34 if os.environ.get("PLAIN_DEV_SERVICES_AUTO", "true") not in [
35 "1",
36 "true",
37 "yes",
38 ]:
39 return
40
41 # Don't do anything if it looks like a "services" command is being run explicitly
42 if "dev" in sys.argv:
43 if "logs" in sys.argv or "services" in sys.argv or "--stop" in sys.argv:
44 return
45
46 if not ServicesProcess.get_services(APP_PATH.parent):
47 return
48
49 if ServicesProcess.running_pid():
50 return
51
52 click.secho(
53 "Starting background dev services (terminate with `plain dev --stop`)...",
54 dim=True,
55 )
56
57 subprocess.Popen(
58 [sys.executable, "-m", "plain", "dev", "services", "--start"],
59 start_new_session=True,
60 stdout=subprocess.DEVNULL,
61 stderr=subprocess.DEVNULL,
62 )
63
64 # Give services time to start and retry the check
65 wait_times = [0.5, 1, 1] # First check at 0.5s, then 1s intervals
66 for wait_time in wait_times:
67 time.sleep(wait_time)
68 if ServicesProcess.running_pid():
69 return # Services started successfully
70
71 # Only show error after multiple attempts
72 if not ServicesProcess.running_pid():
73 click.secho(
74 "Failed to start dev services. Here are the logs:",
75 fg="red",
76 )
77 subprocess.run(
78 ["plain", "dev", "logs", "--services"],
79 check=False,
80 )
81 sys.exit(1)
82
83
84@common_command
85@register_cli("dev")
86@click.group(cls=DevGroup, invoke_without_command=True)
87@click.pass_context
88@click.option(
89 "--port",
90 "-p",
91 default="",
92 type=str,
93 help=(
94 "Port to run the web server on. "
95 "If omitted, tries 8443 and picks the next free port."
96 ),
97)
98@click.option(
99 "--hostname",
100 "-h",
101 default=None,
102 type=str,
103 help="Hostname to run the web server on",
104)
105@click.option(
106 "--log-level",
107 "-l",
108 default="",
109 type=click.Choice(["debug", "info", "warning", "error", "critical", ""]),
110 help="Log level",
111)
112@click.option(
113 "--start",
114 is_flag=True,
115 default=False,
116 help="Start in the background",
117)
118@click.option(
119 "--stop",
120 is_flag=True,
121 default=False,
122 help="Stop the background process",
123)
124def cli(
125 ctx: click.Context,
126 port: str,
127 hostname: str | None,
128 log_level: str,
129 start: bool,
130 stop: bool,
131) -> None:
132 """Local development server"""
133 if ctx.invoked_subcommand:
134 return
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 os.environ["PLAIN_DEV_SERVICES_AUTO"] = "false"
142
143 dev = DevProcess()
144
145 if stop:
146 if ServicesProcess.running_pid():
147 ServicesProcess().stop_process()
148 click.secho("Services stopped.", fg="green")
149
150 if not dev.running_pid():
151 click.secho("No development server running.", fg="yellow")
152 return
153
154 dev.stop_process()
155 click.secho("Development server stopped.", fg="green")
156 return
157
158 if running_pid := dev.running_pid():
159 click.secho(f"`plain dev` already running (pid={running_pid})", fg="yellow")
160 sys.exit(1)
161
162 if start:
163 args = [sys.executable, "-m", "plain", "dev"]
164 if port:
165 args.extend(["--port", port])
166 if hostname:
167 args.extend(["--hostname", hostname])
168 if log_level:
169 args.extend(["--log-level", log_level])
170
171 result = subprocess.Popen(
172 args=args,
173 start_new_session=True,
174 stdout=subprocess.DEVNULL,
175 stderr=subprocess.DEVNULL,
176 )
177 click.secho(
178 f"Development server started in the background (pid={result.pid}).",
179 fg="green",
180 )
181 return
182
183 # Check and prompt for alias setup
184 AliasManager().check_and_prompt()
185
186 dev.setup(
187 port=int(port) if port else None,
188 hostname=hostname,
189 log_level=log_level if log_level else None,
190 )
191 returncode = dev.run()
192 if returncode:
193 sys.exit(returncode)
194
195
196@cli.command()
197def debug() -> None:
198 """Connect to the remote debugger"""
199
200 def _connect() -> subprocess.CompletedProcess[bytes]:
201 if subprocess.run(["which", "nc"], capture_output=True).returncode == 0:
202 return subprocess.run(["nc", "-C", "localhost", "4444"])
203 else:
204 raise OSError("nc not found")
205
206 result = _connect()
207
208 # Try again once without a message
209 if result.returncode == 1:
210 time.sleep(1)
211 result = _connect()
212
213 # Keep trying...
214 while result.returncode == 1:
215 click.secho(
216 "Failed to connect. Make sure remote pdb is ready. Retrying...", fg="red"
217 )
218 result = _connect()
219 time.sleep(1)
220
221
222@cli.command()
223@click.option("--start", is_flag=True, help="Start in the background")
224@click.option("--stop", is_flag=True, help="Stop the background process")
225def services(start: bool, stop: bool) -> None:
226 """Start additional development services"""
227
228 if start and stop:
229 raise click.UsageError(
230 "You cannot use both --start and --stop at the same time."
231 )
232
233 if stop:
234 if not ServicesProcess.running_pid():
235 click.secho("No services running.", fg="yellow")
236 return
237 ServicesProcess().stop_process()
238 click.secho("Services stopped.", fg="green")
239 return
240
241 if running_pid := ServicesProcess.running_pid():
242 click.secho(f"Services already running (pid={running_pid})", fg="yellow")
243 sys.exit(1)
244
245 if start:
246 result = subprocess.Popen(
247 args=[sys.executable, "-m", "plain", "dev", "services"],
248 start_new_session=True,
249 stdout=subprocess.DEVNULL,
250 stderr=subprocess.DEVNULL,
251 )
252 click.secho(
253 f"Services started in the background (pid={result.pid}).", fg="green"
254 )
255 return
256
257 ServicesProcess().run()
258
259
260@cli.command()
261@click.option("--follow", "-f", is_flag=True, help="Follow log output")
262@click.option("--pid", type=int, help="PID to show logs for")
263@click.option("--path", is_flag=True, help="Output log file path")
264@click.option("--services", is_flag=True, help="Show logs for services")
265def logs(follow: bool, pid: int | None, path: bool, services: bool) -> None:
266 """Show recent development logs"""
267
268 if services:
269 log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "services"
270 else:
271 log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "run"
272
273 if pid:
274 log_path = log_dir / f"{pid}.log"
275 if not log_path.exists():
276 click.secho(f"No log found for pid {pid}", fg="red")
277 return
278 else:
279 logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
280 if not logs:
281 click.secho("No logs found", fg="yellow")
282 return
283 log_path = logs[-1]
284
285 if path:
286 click.echo(str(log_path))
287 return
288
289 if follow:
290 subprocess.run(["tail", "-f", str(log_path)])
291 else:
292 with log_path.open() as f:
293 click.echo(f.read())
294
295
296@cli.command()
297@click.option(
298 "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
299)
300@click.argument("entrypoint", required=False)
301def entrypoint(show_list: bool, entrypoint: str | None) -> None:
302 """Run registered development entrypoints"""
303 if not show_list and not entrypoint:
304 raise click.UsageError("Please provide an entrypoint name or use --list")
305
306 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
307 if show_list:
308 click.echo(entry_point.name)
309 elif entrypoint == entry_point.name:
310 entry_point.load()()