Skip to content

Commit 25e6ea4

Browse files
author
xuhaihui
committed
feat(cli): add cccc dev command for one-step dev environment
Start daemon, API server (uvicorn :8848), and Vite dev server (HMR :5173) with a single command. Supports --port to customize the Vite port. Ctrl+C gracefully shuts down all processes.
1 parent dcd2993 commit 25e6ea4

3 files changed

Lines changed: 324 additions & 0 deletions

File tree

src/cccc/cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .space_cmds import * # noqa: F401,F403
1515
from .im_cmds import * # noqa: F401,F403
1616
from .system_cmds import * # noqa: F401,F403
17+
from .dev_cmds import * # noqa: F401,F403
1718
from .main import build_parser, main # noqa: F401
1819

1920
_PATCH_FORWARD_MODULES = (
@@ -24,6 +25,7 @@
2425
"cccc.cli.space_cmds",
2526
"cccc.cli.im_cmds",
2627
"cccc.cli.system_cmds",
28+
"cccc.cli.dev_cmds",
2729
)
2830

2931

src/cccc/cli/dev_cmds.py

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
from __future__ import annotations
2+
3+
"""``cccc dev`` – one-command development environment.
4+
5+
Starts the daemon + API server (uvicorn on :8848) **and** the Vite dev
6+
server (HMR on :5173) so that frontend changes are hot-reloaded.
7+
"""
8+
9+
import argparse
10+
import os
11+
import shutil
12+
import signal
13+
import socket
14+
import subprocess
15+
import sys
16+
import threading
17+
import time
18+
from pathlib import Path
19+
from typing import Optional
20+
21+
from .common import (
22+
__version__,
23+
_env_flag,
24+
_http_host_literal,
25+
call_daemon,
26+
)
27+
28+
__all__ = ["cmd_dev"]
29+
30+
31+
# ---------------------------------------------------------------------------
32+
# helpers
33+
# ---------------------------------------------------------------------------
34+
35+
def _find_web_dir() -> Optional[Path]:
36+
"""Walk up from this file to find the repo-level ``web/`` directory."""
37+
try:
38+
for parent in Path(__file__).resolve().parents:
39+
candidate = parent / "web"
40+
if (candidate / "package.json").exists():
41+
return candidate
42+
except Exception:
43+
pass
44+
return None
45+
46+
47+
def _get_lan_ip() -> str:
48+
try:
49+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
50+
s.settimeout(0.1)
51+
s.connect(("8.8.8.8", 80))
52+
ip = s.getsockname()[0]
53+
s.close()
54+
return ip
55+
except Exception:
56+
return ""
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# cmd_dev
61+
# ---------------------------------------------------------------------------
62+
63+
def cmd_dev(args: argparse.Namespace) -> int:
64+
"""Start daemon + web API + Vite dev server for frontend development."""
65+
from ..paths import ensure_home
66+
67+
web_dir = _find_web_dir()
68+
if web_dir is None:
69+
print("[cccc dev] Error: could not locate web/ directory", file=sys.stderr)
70+
return 1
71+
72+
# Check node_modules
73+
if not (web_dir / "node_modules").exists():
74+
print(f"[cccc dev] Error: {web_dir}/node_modules not found.", file=sys.stderr)
75+
print("[cccc dev] Run: npm install --prefix web", file=sys.stderr)
76+
return 1
77+
78+
# Resolve npm binary
79+
npm_bin = shutil.which("npm")
80+
if npm_bin is None:
81+
print("[cccc dev] Error: npm not found in PATH", file=sys.stderr)
82+
return 1
83+
84+
vite_port = int(getattr(args, "port", 5173) or 5173)
85+
home = ensure_home()
86+
log_path = home / "daemon" / "ccccd.log"
87+
88+
daemon_process: Optional[subprocess.Popen] = None # type: ignore[type-arg]
89+
vite_process: Optional[subprocess.Popen] = None # type: ignore[type-arg]
90+
shutdown_requested = False
91+
92+
# ---- daemon start (reuse _default_entry logic) -----------------------
93+
94+
def _start_daemon() -> bool:
95+
nonlocal daemon_process
96+
resp = call_daemon({"op": "ping"}, timeout_s=1.0)
97+
if resp.get("ok"):
98+
try:
99+
res = resp.get("result") if isinstance(resp.get("result"), dict) else {}
100+
daemon_version = str(res.get("version") or "").strip()
101+
daemon_pid = int(res.get("pid") or 0)
102+
except Exception:
103+
daemon_version = ""
104+
daemon_pid = 0
105+
106+
needs_restart = False
107+
if daemon_version and daemon_version != __version__:
108+
needs_restart = True
109+
110+
if needs_restart:
111+
print(
112+
f"[cccc dev] Daemon version mismatch (running {daemon_version}, expected {__version__}); restarting...",
113+
file=sys.stderr,
114+
)
115+
try:
116+
call_daemon({"op": "shutdown"}, timeout_s=2.0)
117+
except Exception:
118+
pass
119+
deadline = time.time() + 3.0
120+
while time.time() < deadline:
121+
if not call_daemon({"op": "ping"}, timeout_s=0.5).get("ok"):
122+
break
123+
time.sleep(0.1)
124+
if call_daemon({"op": "ping"}, timeout_s=0.5).get("ok") and daemon_pid > 0:
125+
try:
126+
os.kill(daemon_pid, signal.SIGTERM)
127+
except Exception:
128+
pass
129+
if call_daemon({"op": "ping"}, timeout_s=0.5).get("ok"):
130+
print("[cccc dev] Warning: could not stop stale daemon; using existing.", file=sys.stderr)
131+
return True
132+
else:
133+
print("[cccc dev] Daemon already running", file=sys.stderr)
134+
return True
135+
136+
# Spawn daemon
137+
(home / "daemon").mkdir(parents=True, exist_ok=True)
138+
log_file = log_path.open("a", encoding="utf-8")
139+
try:
140+
daemon_process = subprocess.Popen(
141+
[sys.executable, "-m", "cccc.daemon_main", "run"],
142+
stdout=log_file,
143+
stderr=log_file,
144+
env=os.environ.copy(),
145+
start_new_session=True,
146+
)
147+
try:
148+
log_file.close()
149+
except Exception:
150+
pass
151+
except Exception as e:
152+
try:
153+
log_file.close()
154+
except Exception:
155+
pass
156+
print(f"[cccc dev] Failed to start daemon: {e}", file=sys.stderr)
157+
return False
158+
159+
for _ in range(50):
160+
time.sleep(0.1)
161+
if daemon_process.poll() is not None:
162+
print(f"[cccc dev] Daemon crashed! Check log: {log_path}", file=sys.stderr)
163+
try:
164+
lines = log_path.read_text().strip().split("\n")[-20:]
165+
for line in lines:
166+
print(f" {line}", file=sys.stderr)
167+
except Exception:
168+
pass
169+
return False
170+
if call_daemon({"op": "ping"}).get("ok"):
171+
return True
172+
173+
print("[cccc dev] Daemon failed to start in time", file=sys.stderr)
174+
return False
175+
176+
# ---- stop helpers ----------------------------------------------------
177+
178+
def _stop_vite() -> None:
179+
nonlocal vite_process
180+
if vite_process is not None:
181+
try:
182+
vite_process.terminate()
183+
vite_process.wait(timeout=5.0)
184+
except Exception:
185+
try:
186+
vite_process.kill()
187+
except Exception:
188+
pass
189+
vite_process = None
190+
191+
def _stop_daemon() -> None:
192+
nonlocal daemon_process
193+
try:
194+
call_daemon({"op": "shutdown"}, timeout_s=2.0)
195+
except Exception:
196+
pass
197+
if daemon_process is not None:
198+
try:
199+
daemon_process.wait(timeout=10.0)
200+
except subprocess.TimeoutExpired:
201+
try:
202+
daemon_process.terminate()
203+
daemon_process.wait(timeout=2.0)
204+
except Exception:
205+
try:
206+
daemon_process.kill()
207+
except Exception:
208+
pass
209+
daemon_process = None
210+
211+
# ---- monitor threads -------------------------------------------------
212+
213+
def _monitor_daemon() -> None:
214+
nonlocal daemon_process, shutdown_requested
215+
while not shutdown_requested and daemon_process is not None:
216+
ret = daemon_process.poll()
217+
if ret is not None and not shutdown_requested:
218+
print(f"\n[cccc dev] Daemon crashed (exit code {ret})! Check log: {log_path}", file=sys.stderr)
219+
break
220+
time.sleep(1.0)
221+
222+
def _monitor_vite() -> None:
223+
nonlocal vite_process, shutdown_requested
224+
while not shutdown_requested and vite_process is not None:
225+
ret = vite_process.poll()
226+
if ret is not None and not shutdown_requested:
227+
print(f"\n[cccc dev] Vite dev server exited (code {ret})", file=sys.stderr)
228+
break
229+
time.sleep(1.0)
230+
231+
# ---- main flow -------------------------------------------------------
232+
233+
# 1) Start daemon
234+
print("[cccc dev] Starting daemon...", file=sys.stderr)
235+
if not _start_daemon():
236+
print("[cccc dev] Error: could not start daemon", file=sys.stderr)
237+
return 1
238+
print("[cccc dev] Daemon ready", file=sys.stderr)
239+
240+
monitor_d = threading.Thread(target=_monitor_daemon, daemon=True)
241+
monitor_d.start()
242+
243+
# 2) Start uvicorn (API server)
244+
host = str(os.environ.get("CCCC_WEB_HOST") or "").strip() or "0.0.0.0"
245+
api_port = int(os.environ.get("CCCC_WEB_PORT") or 8848)
246+
log_level = str(os.environ.get("CCCC_WEB_LOG_LEVEL") or "").strip() or "info"
247+
reload_mode = _env_flag("CCCC_WEB_RELOAD", default=False)
248+
249+
import uvicorn
250+
251+
config = uvicorn.Config(
252+
"cccc.ports.web.app:create_app",
253+
factory=True,
254+
host=host,
255+
port=api_port,
256+
log_level=log_level,
257+
reload=reload_mode,
258+
timeout_graceful_shutdown=3,
259+
)
260+
server = uvicorn.Server(config)
261+
262+
# Run uvicorn in a background thread so we can manage vite in the main thread
263+
uvicorn_thread = threading.Thread(target=server.run, daemon=True)
264+
uvicorn_thread.start()
265+
266+
# Wait for uvicorn to be ready
267+
for _ in range(50):
268+
time.sleep(0.1)
269+
if server.started:
270+
break
271+
272+
print(f"[cccc dev] API server: http://{_http_host_literal(host)}:{api_port}", file=sys.stderr)
273+
274+
# 3) Start Vite dev server
275+
vite_env = os.environ.copy()
276+
vite_env["BROWSER"] = "none" # don't auto-open browser
277+
try:
278+
vite_process = subprocess.Popen(
279+
[npm_bin, "run", "dev", "--", "--port", str(vite_port), "--strictPort"],
280+
cwd=str(web_dir),
281+
env=vite_env,
282+
)
283+
except Exception as e:
284+
print(f"[cccc dev] Failed to start Vite: {e}", file=sys.stderr)
285+
shutdown_requested = True
286+
server.should_exit = True
287+
_stop_daemon()
288+
return 1
289+
290+
monitor_v = threading.Thread(target=_monitor_vite, daemon=True)
291+
monitor_v.start()
292+
293+
print(f"[cccc dev] Vite HMR: http://localhost:{vite_port}/ui/", file=sys.stderr)
294+
lan_ip = _get_lan_ip()
295+
if lan_ip and lan_ip != "127.0.0.1":
296+
print(f"[cccc dev] Network: http://{lan_ip}:{vite_port}/ui/", file=sys.stderr)
297+
print("[cccc dev] Ready! Press Ctrl+C to stop.", file=sys.stderr)
298+
299+
# 4) Wait for Ctrl+C
300+
try:
301+
while True:
302+
# Exit if vite dies
303+
if vite_process is not None and vite_process.poll() is not None:
304+
break
305+
time.sleep(0.5)
306+
except KeyboardInterrupt:
307+
pass
308+
finally:
309+
print("\n[cccc dev] Shutting down...", file=sys.stderr)
310+
shutdown_requested = True
311+
_stop_vite()
312+
server.should_exit = True
313+
uvicorn_thread.join(timeout=5.0)
314+
_stop_daemon()
315+
print("[cccc dev] Stopped.", file=sys.stderr)
316+
317+
return 0

src/cccc/cli/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .space_cmds import * # noqa: F401,F403
1212
from .im_cmds import * # noqa: F401,F403
1313
from .system_cmds import * # noqa: F401,F403
14+
from .dev_cmds import * # noqa: F401,F403
1415

1516
def build_parser() -> argparse.ArgumentParser:
1617
p = argparse.ArgumentParser(prog="cccc", description="CCCC vNext (working group + scopes)")
@@ -446,6 +447,10 @@ def build_parser() -> argparse.ArgumentParser:
446447
p_space_jobs_cancel.add_argument("--by", default="user", help="Requester (default: user)")
447448
p_space_jobs_cancel.set_defaults(func=cmd_space_jobs_cancel)
448449

450+
p_dev = sub.add_parser("dev", help="Start dev environment (daemon + API + Vite HMR)")
451+
p_dev.add_argument("--port", type=int, default=5173, help="Vite dev server port (default: 5173)")
452+
p_dev.set_defaults(func=cmd_dev)
453+
449454
p_ver = sub.add_parser("version", help="Show version")
450455
p_ver.set_defaults(func=cmd_version)
451456

0 commit comments

Comments
 (0)