Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 33 additions & 49 deletions flocks/cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from __future__ import annotations

import asyncio
import os
import shlex
import shutil
import subprocess
import sys
Expand All @@ -18,14 +18,12 @@


def doctor_command() -> None:
"""Run the source installer from the Flocks source directory."""
"""Repair the active source installation using the shared core installer."""
source_root = _find_source_root()
script = _select_source_install_script(source_root)
command = _build_source_install_command(script)
env = _build_source_install_env()

console.print(f"[cyan]Flocks source directory:[/cyan] {source_root}")
console.print(f"[cyan]Source install command:[/cyan] {_format_command(command)}")
console.print("[cyan]Repairing Flocks core installation...[/cyan]")

if _needs_windows_handoff():
_start_windows_handoff(source_root, env=env)
Expand All @@ -35,12 +33,41 @@ def doctor_command() -> None:
)
return

_run_source_install(command, source_root=source_root, env=env)
_run_core_install(source_root, env=env)

console.print("[green]安装正常[/green]")
_print_service_diagnosis()


def _run_core_install(source_root: Path, *, env: dict[str, str] | None = None) -> None:
"""Run the shared updater/doctor core installation entry point."""
from flocks.updater import updater

uv_path = shutil.which("uv")
if not uv_path:
script_name = "install.ps1" if _is_windows() else "install.sh"
console.print(
f"[red]uv was not found. Run scripts/{script_name} to install system prerequisites.[/red]"
)
raise typer.Exit(1)

install_env = env or os.environ
try:
asyncio.run(
updater.install_or_repair_source(
install_root=source_root,
uv_path=uv_path,
version=updater.get_current_version(),
uv_default_index=install_env.get("FLOCKS_UV_DEFAULT_INDEX"),
npm_registry=install_env.get("FLOCKS_NPM_REGISTRY"),
sync_timeout=300,
)
)
except RuntimeError as error:
console.print(f"[red]Core installation repair failed: {error}[/red]")
raise typer.Exit(1) from error


def _find_source_root(start: Path | None = None) -> Path:
"""Find the repository root that owns the source install scripts."""
current = (start or Path(__file__)).resolve()
Expand All @@ -55,44 +82,6 @@ def _find_source_root(start: Path | None = None) -> Path:
raise typer.BadParameter("Could not locate the Flocks source directory.")


def _select_source_install_script(source_root: Path) -> Path:
"""Select the platform-specific source install script."""
suffix = ".ps1" if _is_windows() else ".sh"
script = source_root / "scripts" / f"install{suffix}"

if not script.is_file():
raise typer.BadParameter(f"Source installer not found: {script}")

return script


def _build_source_install_command(script: Path) -> list[str]:
"""Build the subprocess command for the selected installer."""
if script.suffix == ".ps1":
powershell = _find_powershell()
return [
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
]

return ["bash", str(script)]


def _run_source_install(command: list[str], *, source_root: Path, env: dict[str, str] | None) -> None:
"""Run the selected source installer synchronously."""
try:
subprocess.run(command, cwd=source_root, check=True, env=env)
except FileNotFoundError as error:
console.print(f"[red]Failed to start installer: {error}[/red]")
raise typer.Exit(1) from error
except subprocess.CalledProcessError as error:
raise typer.Exit(error.returncode or 1) from error


def _build_source_install_env() -> dict[str, str] | None:
"""Build installer environment from the persisted install language."""
if not is_cn_install_language():
Expand Down Expand Up @@ -195,11 +184,6 @@ def _service_status_is_healthy(status_lines: list[str]) -> bool:
return backend_running and webui_running


def _format_command(command: list[str]) -> str:
"""Return a shell-readable representation of the command."""
return " ".join(shlex.quote(part) for part in command)


def _is_windows() -> bool:
"""Return whether the current platform should use PowerShell installers."""
return sys.platform.startswith("win")
38 changes: 8 additions & 30 deletions flocks/cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def update_command(


async def _update(check: bool, yes: bool, force: bool = False, region: str | None = None) -> None:
from flocks.updater import build_updated_frontend, check_update, perform_update, detect_deploy_mode
from flocks.updater import check_update, detect_deploy_mode, perform_update
from flocks.cli.install_profile import is_cn_install_language

if region is None and is_cn_install_language():
Expand Down Expand Up @@ -97,26 +97,15 @@ async def _load_update_info(selected_region: str | None):
console.print("[yellow]已取消[/yellow]")
return

from flocks.cli.service_manager import ServiceError, stop_all

try:
stop_all(console)
except ServiceError as error:
console.print(f"[red]执行 flocks stop 失败:{error}[/red]")
raise typer.Exit(1)

append_upgrade_text_log(f"OK cli_update_stopped_services_before_upgrade version={version_to_apply}")
console.print("[green]✓ 已执行 flocks stop[/green]")
console.print()
stage_labels = {
"fetching": "下载最新源码包",
"backing_up": "备份当前版本",
"applying": f"应用 v{info.latest_version}",
"syncing": "同步依赖",
"applying": "应用新版本",
"restarting": "重启服务",
"done": "完成",
}
total_steps = 6
total_steps = 3
seen_stages: set[str] = set()
step = 0
active_stage: str | None = None
Expand All @@ -137,8 +126,9 @@ def _finish_active(success: bool = True) -> None:
tarball_url=info.tarball_url,
bundle_sha256=info.bundle_sha256,
bundle_format=info.bundle_format,
restart=False,
restart=True,
region=region,
wait_for_handoff=True,
):
if progress.stage == "error":
_finish_active(success=False)
Expand All @@ -149,6 +139,9 @@ def _finish_active(success: bool = True) -> None:
_finish_active(success=True)
continue

if progress.stage == "restarting":
continue

if progress.stage not in seen_stages:
_finish_active(success=True)
seen_stages.add(progress.stage)
Expand All @@ -157,23 +150,8 @@ def _finish_active(success: bool = True) -> None:
console.print(f"[cyan][{step}/{total_steps}] {label}...[/cyan] ", end="")
active_stage = progress.stage

step += 1
console.print(f"[cyan][{step}/{total_steps}] 构建前端...[/cyan] ", end="")
try:
await build_updated_frontend(region=region)
except Exception as error:
console.print("[red]✗[/red]")
console.print(f"\n[red]✗ 前端构建失败:{error}[/red]")
raise typer.Exit(1)
console.print("[green]✓[/green]")

step += 1
console.print(f"[cyan][{step}/{total_steps}] 完成[/cyan] ", end="")
console.print("[green]✓[/green]")

append_upgrade_text_log(f"OK cli_update_completed version={version_to_apply}")
console.print(f"\n[green]✓ 升级完成 → v{version_to_apply}[/green]")
console.print("[dim]如需恢复服务,请执行 [bold]flocks start[/bold][/dim]")


def _print_version_table(info) -> None:
Expand Down
17 changes: 0 additions & 17 deletions flocks/cli/service_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,23 +183,6 @@ def request_restart_webui(
return parse_supervisor_status(data)


def request_prepare_upgrade(*, paths=None, timeout: float | None = 30.0) -> SupervisorStatus:
"""Ask the supervisor daemon to pause managed services for upgrade handoff."""
payload = _post_control_json("/upgrade/prepare", paths=paths, timeout=timeout)
return parse_supervisor_status(payload)


def request_resume_upgrade(
config: ServiceConfig,
*,
paths=None,
timeout: float | None = 180.0,
) -> SupervisorStatus:
"""Ask the supervisor daemon to resume managed services after upgrade handoff."""
payload = _post_control_json("/upgrade/resume", payload=service_config_payload(config), paths=paths, timeout=timeout)
return parse_supervisor_status(payload)


def read_logs(
*,
service: str,
Expand Down
61 changes: 0 additions & 61 deletions flocks/cli/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,6 @@ class RuntimeRecord:
started_at: float | None = None


@dataclass(frozen=True)
class UpgradeRuntimeInfo:
payload_present: bool = False
pid_file_present: bool = False
upgrade_pid: int | None = None
frontend_host: str | None = None
frontend_port: int | None = None
listener_pids: tuple[int, ...] = ()
page_active: bool = False

@property
def has_artifacts(self) -> bool:
return self.payload_present or self.pid_file_present


def repo_root() -> Path:
"""Return the installed repository root."""
override = os.getenv("FLOCKS_REPO_ROOT")
Expand Down Expand Up @@ -723,51 +708,6 @@ def _console_print(console, message: str) -> None:
console.print(message)


def _read_upgrade_runtime_info(frontend_port: int | None = None) -> UpgradeRuntimeInfo:
try:
from flocks.updater import updater as updater_module

payload = updater_module.read_upgrade_runtime_state(frontend_port=frontend_port)
except Exception:
return UpgradeRuntimeInfo(frontend_port=frontend_port)

listener_pids = tuple(int(pid) for pid in payload.get("listener_pids", []) if isinstance(pid, int))
return UpgradeRuntimeInfo(
payload_present=bool(payload.get("payload_present")),
pid_file_present=bool(payload.get("pid_file_present")),
upgrade_pid=payload.get("upgrade_pid") if isinstance(payload.get("upgrade_pid"), int) else None,
frontend_host=payload.get("frontend_host") if isinstance(payload.get("frontend_host"), str) else None,
frontend_port=payload.get("frontend_port") if isinstance(payload.get("frontend_port"), int) else frontend_port,
listener_pids=listener_pids,
page_active=bool(payload.get("page_active")),
)


def _resolve_upgrade_runtime(console, *, frontend_port: int, attempt_recover: bool) -> dict[str, object]:
upgrade_info = _read_upgrade_runtime_info(frontend_port)
if not upgrade_info.has_artifacts:
return {"action": "noop", "error": None}

from flocks.updater import updater as updater_module

_console_print(console, "[flocks] 检测到升级临时页残留,正在尝试恢复或清理...")
result = updater_module.resolve_upgrade_runtime_state(
attempt_recover=attempt_recover,
frontend_port=upgrade_info.frontend_port or frontend_port,
)

action = str(result.get("action") or "noop")
error = result.get("error")
if action == "recovered":
_console_print(console, "[flocks] 已恢复未完成升级,正式 WebUI 将继续接管端口。")
elif action != "noop":
_console_print(console, "[flocks] 已清理升级临时页残留。")

if isinstance(error, str) and error:
_console_print(console, f"[flocks] 未完成升级的自动恢复失败,已清理临时升级页: {error}")
return result


def cleanup_stale_pid_file(pid_file: Path) -> None:
"""Remove pid files that no longer point to running processes."""
if not pid_file.exists():
Expand Down Expand Up @@ -1569,7 +1509,6 @@ def _start_all_without_stop(config: ServiceConfig, console) -> None:

def _start_all_unlocked(config: ServiceConfig, console, *, paths: RuntimePaths) -> None:
"""Ensure the supervisor daemon is running; caller must hold lifecycle lock."""
_resolve_upgrade_runtime(console, frontend_port=config.frontend_port, attempt_recover=False)
if supervisor_is_running(paths):
status = None
try:
Expand Down
Loading