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