177 lines
5.5 KiB
Python
177 lines
5.5 KiB
Python
"""CLI entry points for the installed package."""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
import uvicorn
|
|
|
|
from free_claude_code.cli.launchers.common import preflight_proxy
|
|
from free_claude_code.cli.process_registry import (
|
|
kill_all_best_effort,
|
|
)
|
|
from free_claude_code.config.env_migrations import (
|
|
explicit_env_file_huggingface_warning,
|
|
migrate_owned_env_files,
|
|
)
|
|
from free_claude_code.config.env_template import load_env_template
|
|
from free_claude_code.config.paths import (
|
|
config_dir_path,
|
|
legacy_env_paths,
|
|
managed_env_path,
|
|
)
|
|
from free_claude_code.config.server_urls import local_admin_url, local_proxy_root_url
|
|
from free_claude_code.config.settings import Settings, get_settings
|
|
from free_claude_code.core.version import package_version
|
|
from free_claude_code.runtime.bootstrap import build_asgi_app
|
|
|
|
SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5
|
|
|
|
|
|
def serve(argv: Sequence[str] | None = None) -> None:
|
|
"""Start the FastAPI server (registered as `fcc-server` script)."""
|
|
if _print_version_if_requested(argv):
|
|
return
|
|
opened_admin_browser = False
|
|
try:
|
|
try:
|
|
while True:
|
|
_migrate_legacy_env_if_missing()
|
|
_migrate_config_env_keys()
|
|
settings = get_settings()
|
|
if not _run_supervised_server(
|
|
settings, open_admin_browser=not opened_admin_browser
|
|
):
|
|
return
|
|
opened_admin_browser = True
|
|
get_settings.cache_clear()
|
|
except KeyboardInterrupt:
|
|
return
|
|
finally:
|
|
kill_all_best_effort()
|
|
|
|
|
|
def _admin_browser_open_enabled() -> bool:
|
|
"""Whether to open /admin when the server becomes reachable (FCC_OPEN_BROWSER)."""
|
|
|
|
raw = os.environ.get("FCC_OPEN_BROWSER", "true").strip().lower()
|
|
return raw not in {"", "0", "false", "no"}
|
|
|
|
|
|
def _schedule_open_admin_browser(settings: Settings) -> None:
|
|
"""After /health succeeds, open the admin UI in the default browser (daemon thread)."""
|
|
|
|
if not _admin_browser_open_enabled():
|
|
return
|
|
|
|
admin_url = local_admin_url(settings)
|
|
proxy_root_url = local_proxy_root_url(settings)
|
|
|
|
def open_when_ready() -> None:
|
|
deadline = time.monotonic() + 30.0
|
|
while time.monotonic() < deadline:
|
|
if preflight_proxy(proxy_root_url) is None:
|
|
webbrowser.open(admin_url)
|
|
return
|
|
time.sleep(0.15)
|
|
|
|
threading.Thread(
|
|
target=open_when_ready, name="fcc-open-admin-browser", daemon=True
|
|
).start()
|
|
|
|
|
|
def _run_supervised_server(settings: Settings, *, open_admin_browser: bool) -> bool:
|
|
"""Run once; restart only after the old ownership graph fully closes."""
|
|
|
|
restart_requested = False
|
|
server_holder: dict[str, uvicorn.Server] = {}
|
|
|
|
def request_restart() -> None:
|
|
nonlocal restart_requested
|
|
restart_requested = True
|
|
if server := server_holder.get("server"):
|
|
server.should_exit = True
|
|
|
|
asgi_app = build_asgi_app(settings, restart_callback=request_restart)
|
|
config = uvicorn.Config(
|
|
asgi_app,
|
|
host=settings.host,
|
|
port=settings.port,
|
|
log_level="debug",
|
|
timeout_graceful_shutdown=SERVER_GRACEFUL_SHUTDOWN_SECONDS,
|
|
)
|
|
server = uvicorn.Server(config)
|
|
server_holder["server"] = server
|
|
if open_admin_browser:
|
|
_schedule_open_admin_browser(settings)
|
|
server.run()
|
|
return restart_requested and asgi_app.runtime.is_closed
|
|
|
|
|
|
def init(argv: Sequence[str] | None = None) -> None:
|
|
"""Scaffold config at ~/.fcc/.env (registered as `fcc-init`)."""
|
|
if _print_version_if_requested(argv):
|
|
return
|
|
config_dir = config_dir_path()
|
|
env_file = managed_env_path()
|
|
|
|
migrated_from = _migrate_legacy_env_if_missing()
|
|
_migrate_config_env_keys()
|
|
if migrated_from is not None:
|
|
print(f"Config migrated from {migrated_from} to {env_file}")
|
|
print(
|
|
"Edit it to set your API keys and model preferences, then run: fcc-server"
|
|
)
|
|
return
|
|
|
|
if env_file.exists():
|
|
print(f"Config already exists at {env_file}")
|
|
print("Delete it first if you want to reset to defaults.")
|
|
return
|
|
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
template = load_env_template()
|
|
env_file.write_text(template, encoding="utf-8")
|
|
print(f"Config created at {env_file}")
|
|
print("Edit it to set your API keys and model preferences, then run: fcc-server")
|
|
|
|
|
|
def _print_version_if_requested(argv: Sequence[str] | None) -> bool:
|
|
args = sys.argv[1:] if argv is None else argv
|
|
if "--version" not in args:
|
|
return False
|
|
print(f"free-claude-code {package_version()}")
|
|
return True
|
|
|
|
|
|
def _migrate_legacy_env_if_missing() -> Path | None:
|
|
"""Copy a legacy user env into the managed config path when absent."""
|
|
|
|
env_file = managed_env_path()
|
|
if env_file.exists():
|
|
return None
|
|
|
|
# TODO: Remove after the ~/.fcc/.env migration has had a release cycle.
|
|
for legacy_env in legacy_env_paths():
|
|
if not legacy_env.is_file():
|
|
continue
|
|
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(legacy_env, env_file)
|
|
return legacy_env
|
|
|
|
return None
|
|
|
|
|
|
def _migrate_config_env_keys() -> tuple[Path, ...]:
|
|
"""Apply dotenv key migrations before Settings loads config."""
|
|
|
|
migrated = migrate_owned_env_files()
|
|
if warning := explicit_env_file_huggingface_warning(os.environ):
|
|
print(warning, file=sys.stderr)
|
|
return migrated
|