321 lines
8.9 KiB
Python
321 lines
8.9 KiB
Python
"""CLI entry point for the crg-daemon multi-repo watcher.
|
|
|
|
Usage:
|
|
crg-daemon start [--foreground]
|
|
crg-daemon stop
|
|
crg-daemon restart [--foreground]
|
|
crg-daemon status
|
|
crg-daemon logs [--repo ALIAS] [--follow] [--lines N]
|
|
crg-daemon add <path> [--alias ALIAS]
|
|
crg-daemon remove <path_or_alias>
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommand handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _handle_start(args: argparse.Namespace) -> None:
|
|
"""Start the daemon process."""
|
|
from .daemon import WatchDaemon, is_daemon_running, load_config
|
|
|
|
if is_daemon_running():
|
|
print("Error: Daemon is already running.")
|
|
sys.exit(1)
|
|
|
|
config = load_config()
|
|
daemon = WatchDaemon(config=config)
|
|
daemon.start()
|
|
|
|
if not args.foreground:
|
|
daemon.daemonize()
|
|
|
|
daemon.run_forever()
|
|
|
|
|
|
def _handle_stop(_args: argparse.Namespace) -> None:
|
|
"""Stop the running daemon process."""
|
|
from .daemon import clear_pid, is_daemon_running, read_pid
|
|
|
|
if not is_daemon_running():
|
|
print("Daemon is not running.")
|
|
sys.exit(1)
|
|
|
|
pid = read_pid()
|
|
if pid is None:
|
|
print("Error: Could not read daemon PID.")
|
|
sys.exit(1)
|
|
|
|
print(f"Stopping daemon (PID {pid})...")
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
clear_pid()
|
|
print("Daemon stopped (process already gone).")
|
|
return
|
|
except PermissionError:
|
|
print(f"Error: Permission denied sending signal to PID {pid}.")
|
|
sys.exit(1)
|
|
|
|
# Wait up to 5 seconds for process to die
|
|
for _ in range(50):
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
# Still alive after 5s — send SIGKILL
|
|
print("Daemon did not stop gracefully, sending SIGKILL...")
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
clear_pid()
|
|
print("Daemon stopped.")
|
|
|
|
|
|
def _handle_restart(args: argparse.Namespace) -> None:
|
|
"""Restart the daemon (stop + start)."""
|
|
from .daemon import is_daemon_running
|
|
|
|
if is_daemon_running():
|
|
_handle_stop(args)
|
|
else:
|
|
print("Daemon is not running, starting fresh.")
|
|
|
|
_handle_start(args)
|
|
|
|
|
|
def _handle_status(_args: argparse.Namespace) -> None:
|
|
"""Show daemon status and configuration."""
|
|
from .daemon import is_daemon_running, load_config, load_state, pid_alive, read_pid
|
|
|
|
config = load_config()
|
|
running = is_daemon_running()
|
|
|
|
if running:
|
|
pid = read_pid()
|
|
print(f"Daemon: running (PID {pid})")
|
|
else:
|
|
print("Daemon: not running")
|
|
|
|
print(f"Name: {config.session_name}")
|
|
print(f"Log dir: {config.log_dir}")
|
|
print(f"Poll: {config.poll_interval}s")
|
|
print()
|
|
|
|
if not config.repos:
|
|
print("No repositories configured.")
|
|
print("Use: crg-daemon add <path> [--alias NAME]")
|
|
return
|
|
|
|
# Header
|
|
alias_width = max(len(r.alias) for r in config.repos)
|
|
alias_width = max(alias_width, 5) # minimum "Alias" header width
|
|
|
|
if running:
|
|
state = load_state()
|
|
print(f" {'Alias':<{alias_width}} {'Status':<8} {'PID':<8} Path")
|
|
print(f" {'-' * alias_width} {'-' * 8} {'-' * 8} {'-' * 40}")
|
|
for repo in config.repos:
|
|
entry = state.get(repo.alias, {})
|
|
child_pid: int | None = entry.get("pid")
|
|
alive = child_pid is not None and pid_alive(child_pid)
|
|
status_str = "alive" if alive else "dead"
|
|
pid_str = str(child_pid) if child_pid is not None else "-"
|
|
print(f" {repo.alias:<{alias_width}} {status_str:<8} {pid_str:<8} {repo.path}")
|
|
else:
|
|
print(f" {'Alias':<{alias_width}} Path")
|
|
print(f" {'-' * alias_width} {'-' * 40}")
|
|
for repo in config.repos:
|
|
print(f" {repo.alias:<{alias_width}} {repo.path}")
|
|
|
|
|
|
def _handle_logs(args: argparse.Namespace) -> None:
|
|
"""Show daemon or per-repo log files."""
|
|
from .daemon import load_config
|
|
|
|
config = load_config()
|
|
|
|
if args.repo:
|
|
log_file = config.log_dir / f"{args.repo}.log"
|
|
else:
|
|
log_file = config.log_dir / "daemon.log"
|
|
|
|
if not log_file.exists():
|
|
print(f"Log file not found: {log_file}")
|
|
sys.exit(1)
|
|
|
|
if args.follow:
|
|
try:
|
|
subprocess.run(["tail", "-f", str(log_file)], check=False)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return
|
|
|
|
# Read last N lines
|
|
lines_count = args.lines
|
|
try:
|
|
text = log_file.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
print(f"Error reading log file: {exc}")
|
|
sys.exit(1)
|
|
|
|
lines = text.splitlines()
|
|
tail = lines[-lines_count:] if len(lines) > lines_count else lines
|
|
for line in tail:
|
|
print(line)
|
|
|
|
|
|
def _handle_add(args: argparse.Namespace) -> None:
|
|
"""Add a repository to the daemon config."""
|
|
from .daemon import add_repo_to_config, is_daemon_running
|
|
|
|
try:
|
|
add_repo_to_config(args.path, alias=args.alias)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}")
|
|
sys.exit(1)
|
|
|
|
# Find the repo we just added to show confirmation
|
|
alias = args.alias or os.path.basename(os.path.abspath(args.path))
|
|
print(f"Added repository: {args.path} (alias: {alias})")
|
|
|
|
if is_daemon_running():
|
|
print("Daemon will pick up the change automatically.")
|
|
|
|
|
|
def _handle_remove(args: argparse.Namespace) -> None:
|
|
"""Remove a repository from the daemon config."""
|
|
from .daemon import is_daemon_running, load_config, remove_repo_from_config
|
|
|
|
config_before = load_config()
|
|
count_before = len(config_before.repos)
|
|
|
|
config_after = remove_repo_from_config(args.path_or_alias)
|
|
count_after = len(config_after.repos)
|
|
|
|
if count_before == count_after:
|
|
print(f"No repository matching '{args.path_or_alias}' found in config.")
|
|
sys.exit(1)
|
|
|
|
print(f"Removed repository: {args.path_or_alias}")
|
|
|
|
if is_daemon_running():
|
|
print("Daemon will pick up the change automatically.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point for the crg-daemon CLI."""
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
|
|
ap = argparse.ArgumentParser(
|
|
prog="crg-daemon",
|
|
description="Multi-repo watch daemon for code-review-graph",
|
|
)
|
|
sub = ap.add_subparsers(dest="command")
|
|
|
|
# start
|
|
start_cmd = sub.add_parser("start", help="Start the daemon")
|
|
start_cmd.add_argument(
|
|
"--foreground",
|
|
action="store_true",
|
|
help="Run in the foreground instead of daemonizing",
|
|
)
|
|
|
|
# stop
|
|
sub.add_parser("stop", help="Stop the daemon")
|
|
|
|
# restart
|
|
restart_cmd = sub.add_parser("restart", help="Restart the daemon")
|
|
restart_cmd.add_argument(
|
|
"--foreground",
|
|
action="store_true",
|
|
help="Run in the foreground instead of daemonizing",
|
|
)
|
|
|
|
# status
|
|
sub.add_parser("status", help="Show daemon status and configuration")
|
|
|
|
# logs
|
|
logs_cmd = sub.add_parser("logs", help="Show daemon or per-repo logs")
|
|
logs_cmd.add_argument(
|
|
"--repo",
|
|
default=None,
|
|
metavar="ALIAS",
|
|
help="Show logs for a specific repo (by alias)",
|
|
)
|
|
logs_cmd.add_argument(
|
|
"--follow",
|
|
"-f",
|
|
action="store_true",
|
|
help="Follow log output (tail -f)",
|
|
)
|
|
logs_cmd.add_argument(
|
|
"--lines",
|
|
"-n",
|
|
type=int,
|
|
default=50,
|
|
help="Number of lines to show (default: 50)",
|
|
)
|
|
|
|
# add
|
|
add_cmd = sub.add_parser("add", help="Add a repository to the daemon config")
|
|
add_cmd.add_argument("path", help="Path to the repository")
|
|
add_cmd.add_argument(
|
|
"--alias",
|
|
default=None,
|
|
help="Short alias for the repository (default: directory name)",
|
|
)
|
|
|
|
# remove
|
|
remove_cmd = sub.add_parser("remove", help="Remove a repository from the daemon config")
|
|
remove_cmd.add_argument("path_or_alias", help="Repository path or alias to remove")
|
|
|
|
args = ap.parse_args()
|
|
|
|
if not args.command:
|
|
ap.print_help()
|
|
sys.exit(0)
|
|
|
|
handlers: dict[str, object] = {
|
|
"start": _handle_start,
|
|
"stop": _handle_stop,
|
|
"restart": _handle_restart,
|
|
"status": _handle_status,
|
|
"logs": _handle_logs,
|
|
"add": _handle_add,
|
|
"remove": _handle_remove,
|
|
}
|
|
|
|
handler = handlers.get(args.command)
|
|
if handler is None:
|
|
ap.print_help()
|
|
sys.exit(1)
|
|
|
|
handler(args) # type: ignore[operator]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|