From f4813c3536045303cb60de9aaa08229d1feff5d0 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Thu, 2 Jul 2026 17:56:18 +0800 Subject: [PATCH] feat(cli): add doctor source installer repair command --- flocks/cli/commands/__init__.py | 2 + flocks/cli/commands/doctor.py | 130 +++++++++++++++++++ flocks/cli/commands/update.py | 4 + flocks/cli/install_profile.py | 87 +++++++++++++ flocks/cli/main.py | 2 + scripts/install.ps1 | 32 ++++- scripts/install.sh | 22 +++- tests/cli/test_doctor_command.py | 99 ++++++++++++++ tests/cli/test_update_command.py | 57 +++++++- tests/scripts/test_install_script_sources.py | 8 ++ tests/test_install_profile.py | 23 ++++ 11 files changed, 461 insertions(+), 5 deletions(-) create mode 100644 flocks/cli/commands/doctor.py create mode 100644 flocks/cli/install_profile.py create mode 100644 tests/cli/test_doctor_command.py create mode 100644 tests/test_install_profile.py diff --git a/flocks/cli/commands/__init__.py b/flocks/cli/commands/__init__.py index 14d8b464c..4522cc968 100644 --- a/flocks/cli/commands/__init__.py +++ b/flocks/cli/commands/__init__.py @@ -8,6 +8,7 @@ from flocks.cli.commands.import_ import import_app from flocks.cli.commands.mcp import mcp_app from flocks.cli.commands.browser import BROWSER_CONTEXT_SETTINGS, browser_command +from flocks.cli.commands.doctor import doctor_command from flocks.cli.commands.session import session_app from flocks.cli.commands.skill import skill_app from flocks.cli.commands.stats import stats_app @@ -19,6 +20,7 @@ "mcp_app", "browser_command", "BROWSER_CONTEXT_SETTINGS", + "doctor_command", "export_app", "import_app", "stats_app", diff --git a/flocks/cli/commands/doctor.py b/flocks/cli/commands/doctor.py new file mode 100644 index 000000000..30b5deb35 --- /dev/null +++ b/flocks/cli/commands/doctor.py @@ -0,0 +1,130 @@ +"""Source-install repair command for the Flocks CLI.""" + +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +import typer +from rich.console import Console + +from flocks.cli.install_profile import cn_installer_environment, is_cn_install_language + +console = Console() + + +def doctor_command() -> None: + """Run the source installer from the Flocks source directory.""" + 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)}") + + 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 + + console.print("[green]安装正常[/green]") + _print_service_diagnosis() + + +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() + candidates = (current, *current.parents) + + for candidate in candidates: + if candidate.is_file(): + continue + if (candidate / "pyproject.toml").is_file() and (candidate / "scripts" / "install.sh").is_file(): + return candidate + + 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 = shutil.which("pwsh") or shutil.which("powershell") or "powershell" + return [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + ] + + return ["bash", str(script)] + + +def _build_source_install_env() -> dict[str, str] | None: + """Build installer environment from the persisted install language.""" + if not is_cn_install_language(): + return None + env = os.environ.copy() + for key, value in cn_installer_environment().items(): + if key == "FLOCKS_INSTALL_LANGUAGE": + env[key] = value + continue + env.setdefault(key, value) + return env + + +def _print_service_diagnosis() -> None: + """Print a concise post-install service diagnosis.""" + try: + from flocks.cli.service_manager import build_status_lines + + status_lines = build_status_lines() + except Exception as error: + console.print(f"[yellow]服务状态检查失败:{error}[/yellow]") + console.print("[yellow]服务不正常,请执行 `flocks restart`[/yellow]") + return + + for line in status_lines: + console.print(line) + + if _service_status_is_healthy(status_lines): + console.print("[green]服务正常[/green]") + else: + console.print("[yellow]服务不正常,请执行 `flocks restart`[/yellow]") + + +def _service_status_is_healthy(status_lines: list[str]) -> bool: + """Return whether backend and WebUI both look healthy from status lines.""" + backend_running = any("后端运行中" in line for line in status_lines) + webui_running = any("WebUI 运行中" in line for line in status_lines) + 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") diff --git a/flocks/cli/commands/update.py b/flocks/cli/commands/update.py index 6e884643b..c288b9755 100644 --- a/flocks/cli/commands/update.py +++ b/flocks/cli/commands/update.py @@ -36,6 +36,10 @@ 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.cli.install_profile import is_cn_install_language + + if region is None and is_cn_install_language(): + region = "cn" if not yes and not check and region is None: use_cn_mirror = typer.confirm("\n是否使用中国镜像进行升级?", default=False) diff --git a/flocks/cli/install_profile.py b/flocks/cli/install_profile.py new file mode 100644 index 000000000..74bc57239 --- /dev/null +++ b/flocks/cli/install_profile.py @@ -0,0 +1,87 @@ +"""Persisted installer language profile for CLI maintenance commands.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +INSTALL_PROFILE_FILE = "install_profile.json" +INSTALL_PROFILE_LANGUAGE_KEY = "Language" +DEFAULT_INSTALL_LANGUAGE = "en" +CN_INSTALL_LANGUAGE = "zh-CN" + + +def install_profile_path() -> Path: + """Return the install profile path under the Flocks config directory.""" + config_dir = os.getenv("FLOCKS_CONFIG_DIR") + if config_dir: + return Path(config_dir).expanduser() / INSTALL_PROFILE_FILE + + root = os.getenv("FLOCKS_ROOT") + if root: + return Path(root).expanduser() / "config" / INSTALL_PROFILE_FILE + + return Path.home() / ".flocks" / "config" / INSTALL_PROFILE_FILE + + +def normalize_install_language(value: str | None) -> str: + """Normalize a persisted or environment installer language value.""" + language = (value or "").strip() + if _is_cn_language(language): + return CN_INSTALL_LANGUAGE + if language: + return language + return DEFAULT_INSTALL_LANGUAGE + + +def read_install_language() -> str: + """Read the persisted installer language, falling back to the environment.""" + path = install_profile_path() + try: + if path.is_file(): + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + return normalize_install_language(_string_value(payload.get(INSTALL_PROFILE_LANGUAGE_KEY))) + except (OSError, json.JSONDecodeError): + pass + + return normalize_install_language(os.getenv("FLOCKS_INSTALL_LANGUAGE")) + + +def is_cn_install_language(language: str | None = None) -> bool: + """Return whether *language* or the persisted profile selects China mirrors.""" + return _is_cn_language(language or read_install_language()) + + +def cn_installer_environment() -> dict[str, str]: + """Return environment variables equivalent to the zh source installer wrapper.""" + return { + "FLOCKS_INSTALL_LANGUAGE": CN_INSTALL_LANGUAGE, + "FLOCKS_INSTALL_REPO_URL": "https://gitee.com/flocks/flocks.git", + "FLOCKS_RAW_INSTALL_SH_URL": "https://gitee.com/flocks/flocks/raw/main/install_zh.sh", + "FLOCKS_RAW_INSTALL_PS1_URL": "https://gitee.com/flocks/flocks/raw/main/install_zh.ps1", + "FLOCKS_UV_DEFAULT_INDEX": "https://mirrors.aliyun.com/pypi/simple", + "FLOCKS_UV_INSTALL_SH_URL": "https://astral.org.cn/uv/install.sh", + "FLOCKS_UV_INSTALL_SH_FALLBACK_URL": "https://uv.agentsmirror.com/install-cn.sh", + "FLOCKS_UV_INSTALL_SH_SECONDARY_FALLBACK_URL": "https://astral.sh/uv/install.sh", + "FLOCKS_UV_INSTALL_PS1_URL": "https://astral.org.cn/uv/install.ps1", + "FLOCKS_UV_INSTALL_PS1_FALLBACK_URL": "https://uv.agentsmirror.com/install-cn.ps1", + "FLOCKS_UV_INSTALL_PS1_SECONDARY_FALLBACK_URL": "https://astral.sh/uv/install.ps1", + "FLOCKS_NPM_REGISTRY": "https://registry.npmmirror.com/", + "FLOCKS_NVM_INSTALL_SCRIPT_URL": "https://gitee.com/mirrors/nvm/raw/v0.40.3/install.sh", + "PUPPETEER_CHROME_DOWNLOAD_BASE_URL": "https://cdn.npmmirror.com/binaries/chrome-for-testing", + "FLOCKS_NODEJS_MANUAL_DOWNLOAD_URL": "https://nodejs.org/zh-cn/download", + } + + +def _string_value(value: Any) -> str | None: + if isinstance(value, str): + return value + return None + + +def _is_cn_language(language: str | None) -> bool: + normalized = (language or "").strip().lower().replace("_", "-") + return normalized.startswith(("zh", "cn")) diff --git a/flocks/cli/main.py b/flocks/cli/main.py index bdfa8d8d5..37069b6ab 100644 --- a/flocks/cli/main.py +++ b/flocks/cli/main.py @@ -21,6 +21,7 @@ admin_app, BROWSER_CONTEXT_SETTINGS, browser_command, + doctor_command, export_app, import_app, mcp_app, @@ -66,6 +67,7 @@ app.add_typer(admin_app, name="admin") app.command(name="update")(update_command) +app.command(name="doctor")(doctor_command) app.command( name="browser", context_settings=BROWSER_CONTEXT_SETTINGS, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 886c110f5..aac4a1b4b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1016,6 +1016,35 @@ function Install-FlocksCli { } } +function Write-InstallProfile { + $configDir = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_CONFIG_DIR)) { + $flocksRoot = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_ROOT)) { + Join-Path $HOME ".flocks" + } + else { + $env:FLOCKS_ROOT + } + Join-Path $flocksRoot "config" + } + else { + $env:FLOCKS_CONFIG_DIR + } + + try { + $language = if (Test-IsZhInstall) { "zh-CN" } else { "en" } + if (-not (Test-Path $configDir)) { + New-Item -ItemType Directory -Path $configDir -Force | Out-Null + } + + $profilePath = Join-Path $configDir "install_profile.json" + $profile = [ordered]@{ Language = $language } | ConvertTo-Json + [System.IO.File]::WriteAllText($profilePath, $profile + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) + } + catch { + Write-Warning "Failed to write install profile. Continuing with the default installer behavior." + } +} + function Install-Bun { if (Test-Command "bun") { return @@ -1229,6 +1258,7 @@ function Main { } Write-Info (Get-LocalizedText -English "Project directory: $RootDir" -Chinese "项目目录: $RootDir") + Write-InstallProfile Install-Uv Ensure-NpmInstalled Initialize-InstallSources @@ -1320,4 +1350,4 @@ function Main { } } -Main \ No newline at end of file +Main diff --git a/scripts/install.sh b/scripts/install.sh index 8781db298..72246984b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -948,6 +948,25 @@ ensure_env_var_persisted() { fi } +write_install_profile() { + local config_dir language + if [[ -n "${FLOCKS_CONFIG_DIR:-}" ]]; then + config_dir="$FLOCKS_CONFIG_DIR" + else + config_dir="${FLOCKS_ROOT:-$HOME/.flocks}/config" + fi + + if is_zh_install; then + language="zh-CN" + else + language="en" + fi + + if ! { mkdir -p "$config_dir" && printf '{\n "Language": "%s"\n}\n' "$language" > "$config_dir/install_profile.json"; }; then + warn "Failed to write install profile. Continuing with the default installer behavior." + fi +} + detect_system_browser_path() { case "$(uname -s)" in Darwin) @@ -1105,6 +1124,7 @@ main() { else info "Project directory: $ROOT_DIR" fi + write_install_profile install_uv ensure_npm_installed select_install_sources @@ -1193,4 +1213,4 @@ EOF # show_path_update_hint } -main "$@" \ No newline at end of file +main "$@" diff --git a/tests/cli/test_doctor_command.py b/tests/cli/test_doctor_command.py new file mode 100644 index 000000000..28ef05c95 --- /dev/null +++ b/tests/cli/test_doctor_command.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typer.testing import CliRunner + +import flocks.cli.commands.doctor as doctor_cmd +import flocks.cli.main as cli_main + +runner = CliRunner() + + +async def _noop_log_init(**_: object) -> None: + return None + + +def test_doctor_runs_source_installer_from_source_root(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + + calls: list[tuple[list[str], object, bool]] = [] + + def fake_run(command, *, cwd, check, env): + _ = env + calls.append((command, cwd, check)) + + monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr( + "flocks.cli.service_manager.build_status_lines", + lambda: [ + "[flocks] 后端运行中: PID=111 URL=http://127.0.0.1:8000", + "[flocks] WebUI 运行中: PID=222 URL=http://127.0.0.1:5173", + ], + ) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + assert "Flocks source directory:" in result.stdout + assert "scripts/install.sh" in result.stdout + assert "安装正常" in result.stdout + assert "服务正常" in result.stdout + assert len(calls) == 1 + + command, cwd, check = calls[0] + assert command[0] == "bash" + assert command[1].endswith("scripts/install.sh") + assert cwd == doctor_cmd._find_source_root() + assert check is True + + +def test_doctor_uses_cn_environment_for_zh_install_profile(monkeypatch, tmp_path) -> None: + profile = tmp_path / "install_profile.json" + profile.write_text('{"Language": "zh-CN"}', encoding="utf-8") + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + + captured: dict[str, object] = {} + + def fake_run(command, *, cwd, check, env): + captured["command"] = command + captured["cwd"] = cwd + captured["check"] = check + captured["env"] = env + + monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr("flocks.cli.service_manager.build_status_lines", lambda: ["[flocks] 后端未运行", "[flocks] WebUI 未运行"]) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + env = captured["env"] + assert isinstance(env, dict) + assert env["FLOCKS_INSTALL_LANGUAGE"] == "zh-CN" + assert env["FLOCKS_UV_DEFAULT_INDEX"] == "https://mirrors.aliyun.com/pypi/simple" + assert "服务不正常,请执行 `flocks restart`" in result.stdout + + +def test_service_status_is_healthy_requires_backend_and_webui() -> None: + assert doctor_cmd._service_status_is_healthy( + [ + "[flocks] 后端运行中: PID=111 URL=http://127.0.0.1:8000", + "[flocks] WebUI 运行中: PID=222 URL=http://127.0.0.1:5173", + ] + ) + assert not doctor_cmd._service_status_is_healthy(["[flocks] 后端运行中: PID=111", "[flocks] WebUI 未运行"]) + + +def test_doctor_builds_windows_install_command(monkeypatch) -> None: + monkeypatch.setattr(doctor_cmd.shutil, "which", lambda name: None) + + command = doctor_cmd._build_source_install_command(doctor_cmd._find_source_root() / "scripts" / "install.ps1") + + assert command == [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(doctor_cmd._find_source_root() / "scripts" / "install.ps1"), + ] diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py index 6c3104b78..76b5e971e 100644 --- a/tests/cli/test_update_command.py +++ b/tests/cli/test_update_command.py @@ -44,8 +44,45 @@ async def fake_update(*, check: bool, yes: bool, force: bool, region: str | None assert captured == {"check": False, "yes": True, "force": True, "region": "cn"} -def test_update_prompts_for_cn_mirror_before_upgrade_confirmation(monkeypatch) -> None: +def test_update_uses_install_profile_language_as_default_region(monkeypatch, tmp_path) -> None: output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + (tmp_path / "install_profile.json").write_text('{"Language": "zh-CN"}', encoding="utf-8") + monkeypatch.setattr( + update_cmd, + "console", + Console(file=output, force_terminal=False, color_system=None, width=120), + ) + + check_regions: list[str | None] = [] + + async def fake_check_update(*, locale: str | None = None, region: str | None = None) -> VersionInfo: + check_regions.append(region) + return VersionInfo( + current_version="2026.4.1", + latest_version="2026.4.2", + has_update=True, + zipball_url="https://gitee.example.com/flocks.zip", + tarball_url="https://gitee.example.com/flocks.tar.gz", + deploy_mode="source", + update_allowed=True, + ) + + monkeypatch.setattr(updater_pkg, "check_update", fake_check_update) + monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source") + + import asyncio + + asyncio.run(update_cmd._update(check=True, yes=False, force=False, region=None)) + + assert check_regions == ["cn"] + assert "flocks update" in output.getvalue() + + +def test_update_prompts_for_cn_mirror_before_upgrade_confirmation(monkeypatch, tmp_path) -> None: + output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) monkeypatch.setattr( update_cmd, "console", @@ -81,6 +118,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, @@ -88,6 +127,8 @@ async def fake_perform_update( captured["latest_tag"] = latest_tag captured["zipball_url"] = zipball_url captured["tarball_url"] = tarball_url + captured["bundle_sha256"] = bundle_sha256 + captured["bundle_format"] = bundle_format captured["perform_region"] = region captured["restart"] = restart async for step in _fake_progress(): @@ -122,6 +163,8 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str "latest_tag": "2026.4.2", "zipball_url": "https://gitee.example.com/flocks.zip", "tarball_url": "https://gitee.example.com/flocks.tar.gz", + "bundle_sha256": None, + "bundle_format": None, "perform_region": "cn", "restart": False, } @@ -210,8 +253,10 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str assert "升级完成" in output.getvalue() -def test_update_executes_flocks_stop_before_upgrade(monkeypatch) -> None: +def test_update_executes_flocks_stop_before_upgrade(monkeypatch, tmp_path) -> None: output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) monkeypatch.setattr( update_cmd, "console", @@ -238,6 +283,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, @@ -272,8 +319,10 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str assert "已执行 flocks stop" in output.getvalue() -def test_update_reports_frontend_build_failure_after_common_upgrade(monkeypatch) -> None: +def test_update_reports_frontend_build_failure_after_common_upgrade(monkeypatch, tmp_path) -> None: output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) monkeypatch.setattr( update_cmd, "console", @@ -296,6 +345,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, diff --git a/tests/scripts/test_install_script_sources.py b/tests/scripts/test_install_script_sources.py index 18bb7f9b5..cc58186e1 100644 --- a/tests/scripts/test_install_script_sources.py +++ b/tests/scripts/test_install_script_sources.py @@ -155,6 +155,10 @@ def test_main_bash_installer_uses_configured_default_sources_without_probing() - assert 'npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install' in script assert 'npm_config_registry="$NPM_REGISTRY" "$NPX_CMD" --yes @puppeteer/browsers install chrome@stable --path "$browser_dir"' in script assert 'npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install --global agent-browser' in script + assert 'write_install_profile()' in script + assert '"Language": "%s"' in script + assert 'write_install_profile' in script + assert "Failed to write install profile. Continuing with the default installer behavior." in script assert "FLOCKS_NODEJS_MANUAL_DOWNLOAD_URL" in script assert "https://nodejs.org/en/download" in script assert "nodejs_manual_download_hint" in script @@ -201,6 +205,10 @@ def test_main_powershell_installer_uses_configured_default_sources_and_admin_pre assert "irm '$script:UvInstallPs1SecondaryFallbackUrl' | iex" in script assert 'function Assert-Administrator' in script assert 'Assert-Administrator' in script + assert 'function Write-InstallProfile' in script + assert '[ordered]@{ Language = $language }' in script + assert 'Write-InstallProfile' in script + assert "Failed to write install profile. Continuing with the default installer behavior." in script def test_windows_bootstrap_installers_detect_system32_and_fall_back_to_home() -> None: diff --git a/tests/test_install_profile.py b/tests/test_install_profile.py new file mode 100644 index 000000000..395658a08 --- /dev/null +++ b/tests/test_install_profile.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json + +from flocks.cli.install_profile import read_install_language + + +def test_install_profile_round_trips_language(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + + (tmp_path / "install_profile.json").write_text( + json.dumps({"Language": "zh-CN"}), + encoding="utf-8", + ) + + assert read_install_language() == "zh-CN" + + +def test_install_profile_falls_back_to_environment(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("FLOCKS_INSTALL_LANGUAGE", "zh_CN") + + assert read_install_language() == "zh-CN"