diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b03fcf8..089d438b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 @@ -42,7 +42,7 @@ jobs: working-directory: webui steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 285be7f2..be3c8016 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Prepare image variables id: vars diff --git a/.github/workflows/windows-packaging-release.yml b/.github/workflows/windows-packaging-release.yml new file mode 100644 index 00000000..2344968a --- /dev/null +++ b/.github/workflows/windows-packaging-release.yml @@ -0,0 +1,103 @@ +# Build the Windows installer and attach it to the GitHub Release for the pushed tag. + +name: Windows packaging — release installer + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + release-asset: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache bundled tool downloads + uses: actions/cache@v5 + with: + path: C:\Users\runneradmin\AppData\Local\flocks\cache + key: windows-flocks-cache-${{ hashFiles('packaging/windows/versions.manifest.json') }} + restore-keys: | + windows-flocks-cache- + + - name: Install Inno Setup + shell: pwsh + run: | + choco install innosetup --no-progress -y + + - name: Build installer + id: pack + shell: pwsh + run: | + $out = Join-Path $env:RUNNER_TEMP "flocks-staging" + $manifestPath = Join-Path "${{ github.workspace }}" "packaging/windows/versions.manifest.json" + $manifest = Get-Content -Path $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json + $tag = "${{ github.ref_name }}" + $appVersion = $tag.TrimStart('v') + & "${{ github.workspace }}/packaging/windows/build-installer.ps1" ` + -OutputDir $out ` + -RepoRoot "${{ github.workspace }}" ` + -AppVersion $appVersion + $uvExe = Join-Path $out "tools/uv/uv.exe" + $nodeExe = Join-Path $out "tools/node/node.exe" + $chromeExe = Get-ChildItem -Path (Join-Path $out "tools/chrome") -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "chrome-win" } | + Select-Object -First 1 + if (-not (Test-Path $uvExe)) { + throw "uv executable not found in staging: $uvExe" + } + if (-not (Test-Path $nodeExe)) { + throw "node executable not found in staging: $nodeExe" + } + if (-not $chromeExe) { + throw "chrome executable not found in staging under tools/chrome" + } + $uvVersion = (& $uvExe --version).Trim() + $nodeVersion = (& $nodeExe --version).Trim() + $chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.ProductVersion + if ([string]::IsNullOrWhiteSpace($chromeVersion)) { + $chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.FileVersion + } + if ([string]::IsNullOrWhiteSpace($chromeVersion)) { + throw "Failed to resolve bundled chrome version from file metadata" + } + Write-Host "[runtime] pinned uv version: $($manifest.uv.version)" + Write-Host "[runtime] bundled uv version: $uvVersion" + Write-Host "[runtime] pinned node version: $($manifest.nodejs.version)" + Write-Host "[runtime] bundled node version: $nodeVersion" + Write-Host "[runtime] pinned chrome version: $($manifest.chrome_for_testing.version)" + Write-Host "[runtime] bundled chrome version: $chromeVersion" + if ($uvVersion -notmatch ("^uv\s+" + [regex]::Escape($manifest.uv.version) + "(\s|$)")) { + throw "Bundled uv version does not match pinned version in manifest" + } + if ($nodeVersion -ne ("v" + $manifest.nodejs.version)) { + throw "Bundled node version does not match pinned version in manifest" + } + if ($chromeVersion -notmatch [regex]::Escape($manifest.chrome_for_testing.version)) { + throw "Bundled chrome version does not match pinned version in manifest" + } + $builtInstaller = Join-Path "${{ github.workspace }}" "packaging/windows/Output/FlocksSetup.exe" + if (-not (Test-Path $builtInstaller)) { + throw "Installer not found: $builtInstaller" + } + $name = "FlocksSetup-${tag}.exe" + $installer = Join-Path $env:RUNNER_TEMP $name + Copy-Item -Path $builtInstaller -Destination $installer -Force + Add-Content -Path $env:GITHUB_OUTPUT -Value "installer_path=$installer" -Encoding utf8NoBOM + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.pack.outputs.installer_path }} + generate_release_notes: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml new file mode 100644 index 00000000..f77afb48 --- /dev/null +++ b/.github/workflows/windows-packaging.yml @@ -0,0 +1,97 @@ +name: Windows packaging (installer) + +on: + workflow_dispatch: + pull_request: + paths: + - "packaging/windows/**" + - "scripts/install.ps1" + - "scripts/install_zh.ps1" + - "flocks/cli/service_manager.py" + - ".github/workflows/windows-packaging.yml" + - ".github/workflows/windows-packaging-release.yml" + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + installer: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache bundled tool downloads + uses: actions/cache@v5 + with: + path: C:\Users\runneradmin\AppData\Local\flocks\cache + key: windows-flocks-cache-${{ hashFiles('packaging/windows/versions.manifest.json') }} + restore-keys: | + windows-flocks-cache- + + - name: Install Inno Setup + shell: pwsh + run: | + choco install innosetup --no-progress -y + + - name: Build installer + shell: pwsh + run: | + $out = Join-Path $env:RUNNER_TEMP "flocks-staging" + $manifestPath = Join-Path "${{ github.workspace }}" "packaging/windows/versions.manifest.json" + $manifest = Get-Content -Path $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json + & "${{ github.workspace }}/packaging/windows/build-installer.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" + $uvExe = Join-Path $out "tools/uv/uv.exe" + $nodeExe = Join-Path $out "tools/node/node.exe" + $chromeExe = Get-ChildItem -Path (Join-Path $out "tools/chrome") -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "chrome-win" } | + Select-Object -First 1 + if (-not (Test-Path $uvExe)) { + throw "uv executable not found in staging: $uvExe" + } + if (-not (Test-Path $nodeExe)) { + throw "node executable not found in staging: $nodeExe" + } + if (-not $chromeExe) { + throw "chrome executable not found in staging under tools/chrome" + } + $uvVersion = (& $uvExe --version).Trim() + $nodeVersion = (& $nodeExe --version).Trim() + $chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.ProductVersion + if ([string]::IsNullOrWhiteSpace($chromeVersion)) { + $chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.FileVersion + } + if ([string]::IsNullOrWhiteSpace($chromeVersion)) { + throw "Failed to resolve bundled chrome version from file metadata" + } + Write-Host "[runtime] pinned uv version: $($manifest.uv.version)" + Write-Host "[runtime] bundled uv version: $uvVersion" + Write-Host "[runtime] pinned node version: $($manifest.nodejs.version)" + Write-Host "[runtime] bundled node version: $nodeVersion" + Write-Host "[runtime] pinned chrome version: $($manifest.chrome_for_testing.version)" + Write-Host "[runtime] bundled chrome version: $chromeVersion" + if ($uvVersion -notmatch ("^uv\s+" + [regex]::Escape($manifest.uv.version) + "(\s|$)")) { + throw "Bundled uv version does not match pinned version in manifest" + } + if ($nodeVersion -ne ("v" + $manifest.nodejs.version)) { + throw "Bundled node version does not match pinned version in manifest" + } + if ($chromeVersion -notmatch [regex]::Escape($manifest.chrome_for_testing.version)) { + throw "Bundled chrome version does not match pinned version in manifest" + } + $exe = Join-Path "${{ github.workspace }}" "packaging/windows/Output/FlocksSetup.exe" + if (-not (Test-Path $exe)) { + throw "Installer not found: $exe" + } + Copy-Item -Path $exe -Destination (Join-Path $env:RUNNER_TEMP "FlocksSetup.exe") -Force + + - name: Upload installer artifact + uses: actions/upload-artifact@v7 + with: + name: flocks-windows-installer + path: ${{ runner.temp }}/FlocksSetup.exe + if-no-files-found: error + retention-days: 90 diff --git a/.gitignore b/.gitignore index 0744fbef..45739c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,9 @@ webui/.env.production.local webui/.vite/deps/_metadata.json .vite +# Windows packaging outputs +packaging/windows/Output/ + .flocks/.storage_migrated artifacts/ prompt.txt diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 2d826657..27f0972b 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -245,9 +245,58 @@ def resolve_flocks_cli_command(root: Path | None = None) -> list[str]: return resolve_python_subprocess_command(root) + ["-m", "flocks.cli.main"] +def _bundled_node_install_dir() -> Path | None: + """Return the bundled Node.js installation directory when available.""" + candidates: list[str] = [] + node_home = os.getenv("FLOCKS_NODE_HOME") + if node_home: + candidates.append(node_home) + + install_root = os.getenv("FLOCKS_INSTALL_ROOT") + if install_root: + candidates.append(str(Path(install_root).expanduser() / "tools" / "node")) + + for candidate in candidates: + node_dir = Path(candidate).expanduser() + if sys.platform == "win32": + node_executable = node_dir / "node.exe" + else: + node_executable = node_dir / "bin" / "node" + if node_executable.exists(): + return node_dir.resolve() + return None + + +def resolve_node_executable() -> str | None: + """Resolve node executable from bundled toolchain first, then PATH.""" + node_dir = _bundled_node_install_dir() + if node_dir is not None: + node_executable = node_dir / ("node.exe" if sys.platform == "win32" else "bin/node") + return str(node_executable) + return which("node") + + +def resolve_npm_executable() -> str | None: + """Resolve npm from bundled toolchain first, then PATH.""" + node_dir = _bundled_node_install_dir() + if node_dir is not None: + candidates = ( + [node_dir / "npm.cmd", node_dir / "npm", node_dir / "bin/npm"] + if sys.platform == "win32" + else [node_dir / "bin/npm", node_dir / "npm"] + ) + for candidate in candidates: + if candidate.exists(): + return str(candidate) + + if sys.platform == "win32": + return which("npm.cmd") or which("npm") + return which("npm") or which("npm.cmd") + + def get_node_major_version() -> int | None: """Return the detected Node.js major version.""" - node = which("node") + node = resolve_node_executable() if not node: return None @@ -785,7 +834,7 @@ def start_frontend(config: ServiceConfig, console) -> None: if runtime_record is not None: paths.frontend_pid.unlink(missing_ok=True) - npm = which("npm") or which("npm.cmd") + npm = resolve_npm_executable() if not npm: raise ServiceError("未检测到 npm,请先安装 Node.js 22+(包含 npm)后重试。") if not node_version_satisfies_requirement(): @@ -1343,6 +1392,22 @@ def build_frontend_env(config: ServiceConfig) -> dict[str, str]: """Build frontend proxy environment variables from backend service settings.""" env = os.environ.copy() env["FLOCKS_API_PROXY_TARGET"] = backend_access_base_url(config) + + # When using the bundled toolchain (Windows installer), npm/node spawned by + # `npm run build/preview` must be able to locate the bundled node.exe via + # PATH — npm itself does not always inherit the caller's resolved executable + # location. Prepend the bundled node directory when present. + node_dir = _bundled_node_install_dir() + if node_dir is not None: + if sys.platform == "win32": + node_bin = str(node_dir) + else: + node_bin = str(node_dir / "bin") + path_sep = os.pathsep + current_path = env.get("PATH", "") + if node_bin not in current_path.split(path_sep): + env["PATH"] = node_bin + path_sep + current_path + return env diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index 411e96ba..eb05e5ab 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -115,6 +115,25 @@ def _running_from_legacy_uv_tool_install() -> bool: return "/uv/tools/flocks/" in executable +def _bundled_node_install_dir() -> Path | None: + """Return bundled Node.js install dir when Windows installer env vars are set.""" + candidates: list[str] = [] + node_home = os.getenv("FLOCKS_NODE_HOME") + if node_home: + candidates.append(node_home) + + install_root = os.getenv("FLOCKS_INSTALL_ROOT") + if install_root: + candidates.append(str(Path(install_root).expanduser() / "tools" / "node")) + + for candidate in candidates: + node_dir = Path(candidate).expanduser() + node_executable = node_dir / ("node.exe" if sys.platform == "win32" else "bin/node") + if node_executable.exists(): + return node_dir.resolve() + return None + + def _windows_paths_match(left: str, right: str) -> bool: """Return True when two Windows paths likely point to the same launcher/script.""" if not left or not right: @@ -1857,7 +1876,7 @@ async def perform_update( # ------------------------------------------------------------------ # staged_webui_dir = content_root / "webui" if staged_webui_dir.is_dir() and (staged_webui_dir / "package.json").exists(): - npm = _find_executable("npm.cmd") or _find_executable("npm") + npm = _resolve_npm_executable() if npm: yield UpdateProgress(stage="building", message="Installing frontend dependencies...") npm_env = {"npm_config_registry": profile.npm_registry} if profile.npm_registry else None @@ -2306,3 +2325,21 @@ def _find_executable(name: str) -> str | None: return str(p) return None + + +def _resolve_npm_executable() -> str | None: + """Resolve npm from bundled Node first, then standard executable probing.""" + node_dir = _bundled_node_install_dir() + if node_dir is not None: + candidates = ( + [node_dir / "npm.cmd", node_dir / "npm", node_dir / "bin" / "npm"] + if sys.platform == "win32" + else [node_dir / "bin" / "npm", node_dir / "npm"] + ) + for candidate in candidates: + if candidate.exists(): + return str(candidate) + + if sys.platform == "win32": + return _find_executable("npm.cmd") or _find_executable("npm") + return _find_executable("npm") or _find_executable("npm.cmd") diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 00000000..abd6bca8 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,81 @@ +# 打包说明 + +本目录包含 **Windows 安装包(Inno Setup)** 相关脚本与配置。产物为 **`FlocksSetup.exe`**(安装向导),不是 PyInstaller 等单文件可执行程序。 + +## 目录结构 + +| 路径 | 说明 | +|------|------| +| `windows/build-staging.ps1` | 生成分发用的 **staging 目录**:下载并解压 uv、Node.js、Chrome for Testing,并 `robocopy` 复制仓库(不含 `.git`、`.venv`、`node_modules`)。**不包含预建 `.venv`**,安装后由 `scripts/install.ps1` 等完成引导。 | +| `windows/build-installer.ps1` | 一键:先跑 staging,再用 Inno Setup 编译安装包。 | +| `windows/flocks-setup.iss` | Inno Setup 6 工程文件;编译器为 `ISCC.exe`。 | +| `windows/bootstrap-windows.ps1` | 将已复制到目标机的 staging(含 `tools\`、`flocks\`)与用户环境衔接(PATH、`FLOCKS_*` 等),供安装后或手动场景使用。 | +| `windows/uninstall-flocks-user-state.ps1` | 由 Inno **`[UninstallRun]`** 在删除安装目录**之前**调用:优先 **`flocks stop`**,再 `taskkill` 兜底;从**用户** PATH 去掉**任意**位于 `{app}` 下的路径段(含 `bin`、`tools\uv`、`tools\node` 等);删除指向本安装的 `%USERPROFILE%\.local\bin\flocks*`;按精确值清理用户级 `FLOCKS_*`;仅当 `AGENT_BROWSER_EXECUTABLE_PATH` 指向安装目录内文件时清除;删除桌面/开始菜单快捷方式;按需移除 `~/.flocks/browser/bundled` 联接。**不删除** `~/.flocks` 下用户数据(日志、workspace 等)。 | +| `windows/versions.manifest.json` | 锁定的 **uv / Node / Chrome for Testing** 版本,打 reproducible 包时在此升级。 | +| `windows/staging-layout.json` | staging 目录约定说明(机器可读摘要)。 | +| `windows/DOWNLOAD-HOSTING.txt` | 构建产物在 CI Artifact 与 GitHub Release 上的存放与保留策略说明。 | + +## 本地打包前置条件 + +1. **Windows**,PowerShell 5+(脚本按 Windows PowerShell 编写)。 +2. **网络**:staging 需从 GitHub、nodejs.org、Google 存储等下载工具链压缩包。 +3. **Inno Setup 6** 已安装,且默认路径存在编译器: + `C:\Program Files (x86)\Inno Setup 6\ISCC.exe` + 若安装路径不同,调用 `build-installer.ps1` 时使用 `-InnoSetupCompilerPath` 指向你的 `ISCC.exe`。 + +## 推荐命令(仓库根目录) + +**一键生成安装包**(staging 默认输出到仓库**上一级**目录下的 `agentflocks`,安装包输出到 `packaging/windows/Output/`): + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\packaging\windows\build-installer.ps1 +``` + +常用可选参数: + +| 参数 | 含义 | +|------|------| +| `-OutputDir` | staging 输出目录;不设则默认为 `{仓库父目录}\agentflocks`。 | +| `-RepoRoot` | 仓库根路径;默认即为当前仓库根。 | +| `-AppVersion` | 写入安装包的版本字符串(发版时与 tag 对齐)。 | +| `-CacheRoot` | 下载缓存根目录;不设则按 `build-staging.ps1` 内 `Resolve-CacheRoot` 规则解析(如环境变量 `FLOCKS_CACHE_ROOT`、父目录下已有 `flocks_deps`、`%LOCALAPPDATA%\flocks\cache` 等)。 | +| `-InnoSetupCompilerPath` | `ISCC.exe` 的完整路径。 | + +**仅生成 staging、不编安装包**: + +```powershell +.\packaging\windows\build-staging.ps1 -OutputDir C:\path\to\staging -RepoRoot $PWD +``` + +## 产物位置 + +- **安装包**:`packaging\windows\Output\FlocksSetup.exe` +- **Staging 根目录**:由 `-OutputDir` 或上述默认值决定,其下包含 `tools\`(uv、node、chrome)与 `flocks\`(仓库副本)等,详见 `staging-layout.json`。 + +## 版本与缓存 + +- 升级捆绑的 **uv / Node / Chrome for Testing**:编辑 `windows/versions.manifest.json` 中对应字段后重新打包。 +- 重复打包时,已下载的 zip 会在 **CacheRoot** 下复用,可减少下载时间。 + +## CI + +- **PR / 手动触发**:`.github/workflows/windows-packaging.yml` — 在 `windows-latest` 上安装 Inno Setup(Chocolatey),执行 `build-installer.ps1`,上传 **`FlocksSetup.exe`** 为 Artifact(保留天数见 workflow)。 +- **打 tag 发版**:`.github/workflows/windows-packaging-release.yml` — 推送 `v*` 标签时构建安装包并作为 **GitHub Release** 资源上传。 + +更长期的下载与 Artifact 过期策略见 `windows/DOWNLOAD-HOSTING.txt`。 + +## 安装后说明 + +安装程序会向用户环境写入 `FLOCKS_INSTALL_ROOT` 等变量;**安装完成后需新开终端**,再执行 `flocks start` 等命令,以便新进程继承 PATH 与相关环境变量(Inno 向导结束页亦有英文/中文提示)。 + +## 卸载说明 + +通过系统「应用和功能」或 Inno 卸载程序卸载时,会执行 `uninstall-flocks-user-state.ps1`,**先**在安装目录仍存在时运行 **`flocks stop`**,再对仍存活的 PID 做强制结束;并与 `flocks-setup.iss` 中 **`[Registry]`** 的 `uninsdeletevalue`(`FLOCKS_INSTALL_ROOT` / `FLOCKS_REPO_ROOT` / `FLOCKS_NODE_HOME`)一起,清理安装时写入的用户级环境。**用户 PATH** 中凡是以 `{app}\` 为前缀的目录(含 `bootstrap-windows.ps1` 写入的 `tools\uv`、`tools\node`,以及可能出现的 `{app}\bin` 等)均由卸载脚本**整段移除**;`Path` 本身无法靠 `uninsdeletevalue` 自动还原,必须脚本处理。 + +**不会**删除 **`%USERPROFILE%\.flocks`** 目录(用户数据);仅删除安装期创建的 **`browser\bundled`** 目录联接(若存在)。 + +**不会**删除整个 `%USERPROFILE%\.local\bin` 目录或从 PATH 中整体移除该目录(避免影响用户在同一目录下的其他工具);仅当 `flocks.cmd` / `flocks.exe` 内容包含当前安装根路径时,才删除这些包装文件。 + +卸载完成后请**新开终端**,以便进程看到更新后的 PATH 与环境变量。 + +卸载时会删除**桌面**上的 `Flocks.lnk`(若安装时勾选了桌面快捷方式)以及「开始」菜单程序组 `Flocks` 下的快捷方式:`[UninstallDelete]` 与 `uninstall-flocks-user-state.ps1` 中的 `Remove-FlocksShellShortcuts` 互为补充(含 OneDrive 重定向后的桌面路径)。 diff --git a/packaging/windows/DOWNLOAD-HOSTING.txt b/packaging/windows/DOWNLOAD-HOSTING.txt new file mode 100644 index 00000000..67eb6573 --- /dev/null +++ b/packaging/windows/DOWNLOAD-HOSTING.txt @@ -0,0 +1,20 @@ +Windows staging zip: where it lives and how long it stays downloadable +======================================================================= + +1) GitHub Actions "Artifacts" (workflow: windows-packaging.yml) + - Each workflow run uploads a zip (e.g. flocks-windows-staging.zip). + - Retention: GitHub enforces a maximum retention period (often 90 days for + public repos on Free; orgs may allow longer). The workflow sets an explicit + retention-days so behavior is predictable. + - Artifacts are NOT permanent hosting. Old runs expire and the zip disappears. + +2) Long-term / "always" download for users + - Attach the same zip (or your Inno Setup FlocksSetup.exe) to a GitHub + RELEASE (tag, e.g. v1.2.3). Release assets stay available as long as the + repo and release exist (subject to GitHub policies). + - Use workflow: windows-packaging-release.yml — runs when you push a version + tag (v*), builds the staging zip, uploads it to that GitHub Release. + +3) Manual + - Build locally: packaging\windows\build-staging.ps1 + - Upload the zip or Setup.exe to Release assets by hand in the GitHub UI. diff --git a/packaging/windows/bootstrap-windows.ps1 b/packaging/windows/bootstrap-windows.ps1 new file mode 100644 index 00000000..a6136d60 --- /dev/null +++ b/packaging/windows/bootstrap-windows.ps1 @@ -0,0 +1,141 @@ +# Tier-B / bundled-toolchain bootstrap: run after copying staging (tools\ + flocks\) to the target machine. +# Requires FLOCKS_INSTALL_ROOT (or -InstallRoot) pointing at the directory that contains tools\ and flocks\. +# +# Design goal: keep scripts\install.ps1 / install_zh.ps1 unaware of the bundled layout. +# This script is the only place that does the "glue" work — injecting tools\uv and tools\node +# into the User PATH and exposing tools\chrome under ~/.flocks/browser so the upstream +# installer naturally discovers them without any bundled-specific branches. +# +# Example (installer post-install or manual): +# $env:FLOCKS_INSTALL_ROOT = "D:\Flocks" +# powershell -NoProfile -ExecutionPolicy Bypass -File .\packaging\windows\bootstrap-windows.ps1 +# +# Optional: pass through -InstallTui to match scripts\install.ps1. + +param( + [string]$InstallRoot = $env:FLOCKS_INSTALL_ROOT, + [switch]$InstallTui +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($InstallRoot)) { + Write-Host "[flocks-bootstrap] error: set -InstallRoot or environment variable FLOCKS_INSTALL_ROOT to the install root (must contain tools\ and flocks\)." -ForegroundColor Red + exit 1 +} + +$InstallRoot = $InstallRoot.TrimEnd('\', '/') +$env:FLOCKS_INSTALL_ROOT = $InstallRoot +$env:FLOCKS_REPO_ROOT = (Join-Path $InstallRoot "flocks") +$env:FLOCKS_NODE_HOME = (Join-Path $InstallRoot "tools\node") + +# Allow install.ps1 to skip its Administrator assertion — Inno Setup installs to +# {localappdata} with PrivilegesRequired=lowest, so bootstrap runs as the regular user. +$env:FLOCKS_SKIP_ADMIN_CHECK = "1" + +if ([string]::IsNullOrWhiteSpace($env:FLOCKS_INSTALL_LANGUAGE)) { + $env:FLOCKS_INSTALL_LANGUAGE = "zh-CN" +} +if ([string]::IsNullOrWhiteSpace($env:FLOCKS_UV_DEFAULT_INDEX)) { + $env:FLOCKS_UV_DEFAULT_INDEX = "https://mirrors.aliyun.com/pypi/simple" +} +if ([string]::IsNullOrWhiteSpace($env:FLOCKS_NPM_REGISTRY)) { + $env:FLOCKS_NPM_REGISTRY = "https://registry.npmmirror.com/" +} + +function Add-UserPathEntryIfMissing { + param([string]$Entry) + + if ([string]::IsNullOrWhiteSpace($Entry)) { return } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ([string]::IsNullOrWhiteSpace($userPath)) { + $userPath = "" + } + $existing = $userPath.Split(';') | Where-Object { $_ -and ($_.TrimEnd('\','/')).ToLower() -eq $Entry.TrimEnd('\','/').ToLower() } + if (-not $existing) { + $updated = if ([string]::IsNullOrWhiteSpace($userPath)) { $Entry } else { "$Entry;$userPath" } + [Environment]::SetEnvironmentVariable("Path", $updated, "User") + Write-Host "[flocks-bootstrap] added to User PATH: $Entry" + } + + # Also make the entry available to the current process so install.ps1's + # `Test-Command uv` / `npm.cmd` probes succeed immediately. + $processPath = $env:Path + if (-not ($processPath -split ';' | Where-Object { ($_.TrimEnd('\','/')).ToLower() -eq $Entry.TrimEnd('\','/').ToLower() })) { + $env:Path = "$Entry;$processPath" + } +} + +# 1) Surface bundled uv / node so install.ps1's Test-Command "uv" / "npm.cmd" are satisfied +# without install.ps1 ever referencing FLOCKS_INSTALL_ROOT. +$bundledUv = Join-Path $InstallRoot "tools\uv" +if (Test-Path (Join-Path $bundledUv "uv.exe")) { + Add-UserPathEntryIfMissing -Entry $bundledUv +} +else { + Write-Host "[flocks-bootstrap] warning: bundled uv not found at $bundledUv" -ForegroundColor Yellow +} + +$bundledNode = Join-Path $InstallRoot "tools\node" +if (Test-Path (Join-Path $bundledNode "npm.cmd")) { + Add-UserPathEntryIfMissing -Entry $bundledNode +} +else { + Write-Host "[flocks-bootstrap] warning: bundled node not found at $bundledNode" -ForegroundColor Yellow +} + +# 2) Expose bundled Chrome for Testing under ~/.flocks/browser so install.ps1's +# Resolve-ChromeForTestingPath finds it and skips the real download. +# Prefer a directory junction (fast, no disk duplication) and fall back to copy. +$bundledChrome = Join-Path $InstallRoot "tools\chrome" +if (Test-Path $bundledChrome) { + $browserDir = Join-Path $HOME ".flocks\browser" + if (-not (Test-Path $browserDir)) { + New-Item -ItemType Directory -Path $browserDir -Force | Out-Null + } + $target = Join-Path $browserDir "bundled" + + $needsLink = $true + if (Test-Path $target) { + $existing = Get-Item -Path $target -Force -ErrorAction SilentlyContinue + if ($existing -and $existing.Attributes -band [IO.FileAttributes]::ReparsePoint) { + # Already a junction — leave it in place. + $needsLink = $false + } + else { + # Plain directory from an earlier run — remove and recreate as junction. + Remove-Item -Path $target -Recurse -Force -ErrorAction SilentlyContinue + } + } + + if ($needsLink) { + & cmd /c "mklink /J `"$target`" `"$bundledChrome`"" | Out-Null + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $target)) { + Write-Host "[flocks-bootstrap] junction failed, falling back to copy for bundled Chrome" -ForegroundColor Yellow + Copy-Item -Path $bundledChrome -Destination $target -Recurse -Force + } + else { + Write-Host "[flocks-bootstrap] linked bundled Chrome: $target -> $bundledChrome" + } + } +} +else { + Write-Host "[flocks-bootstrap] note: bundled chrome directory not present at $bundledChrome" -ForegroundColor Yellow +} + +# 3) Hand off to the regular installer. install_zh.ps1 sees a standard source checkout +# (FLOCKS_REPO_ROOT) plus uv/node already on PATH and Chrome under ~/.flocks/browser. +$installer = Join-Path $InstallRoot "flocks\scripts\install_zh.ps1" +if (-not (Test-Path $installer)) { + Write-Host "[flocks-bootstrap] error: installer not found: $installer" -ForegroundColor Red + exit 1 +} + +$installerArgs = @() +if ($InstallTui) { + $installerArgs += "-InstallTui" +} + +& powershell -NoProfile -ExecutionPolicy Bypass -File $installer @installerArgs +exit $LASTEXITCODE diff --git a/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 new file mode 100644 index 00000000..1f1a56b0 --- /dev/null +++ b/packaging/windows/build-installer.ps1 @@ -0,0 +1,58 @@ +param( + [string]$OutputDir = "", + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [string]$ManifestPath = (Join-Path $PSScriptRoot "versions.manifest.json"), + [string]$InnoSetupCompilerPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", + [string]$CacheRoot = "", + [string]$AppVersion = "" +) + +$ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($OutputDir)) { + $OutputDir = Join-Path (Split-Path -Parent $RepoRoot) "agentflocks" +} + +if (-not (Test-Path $InnoSetupCompilerPath)) { + throw "Inno Setup compiler not found: $InnoSetupCompilerPath" +} + +$buildStagingScript = Join-Path $PSScriptRoot "build-staging.ps1" +$installerScript = Join-Path $PSScriptRoot "flocks-setup.iss" + +if (-not (Test-Path $buildStagingScript)) { + throw "build-staging.ps1 not found: $buildStagingScript" +} +if (-not (Test-Path $installerScript)) { + throw "Installer script not found: $installerScript" +} + +Write-Host "[build-installer] Building staging directory..." +# When -CacheRoot is empty, do not pass it: nested `powershell -File ... -CacheRoot $empty` +# can drop the value and leave -CacheRoot without an argument (PS 5.1). +$stagingInvoke = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', $buildStagingScript, + '-OutputDir', $OutputDir, + '-RepoRoot', $RepoRoot, + '-ManifestPath', $ManifestPath +) +if (-not [string]::IsNullOrWhiteSpace($CacheRoot)) { + $stagingInvoke += @('-CacheRoot', $CacheRoot) +} +& powershell.exe @stagingInvoke +if ($LASTEXITCODE -ne 0) { + throw "Staging build failed with exit code $LASTEXITCODE" +} + +Write-Host "[build-installer] Compiling Inno Setup installer..." +$isccArgs = @($installerScript, ("/DStagingRoot=" + $OutputDir)) +if (-not [string]::IsNullOrWhiteSpace($AppVersion)) { + $isccArgs += "/DAppVersion=$AppVersion" +} +& $InnoSetupCompilerPath @isccArgs +if ($LASTEXITCODE -ne 0) { + throw "Inno Setup compilation failed with exit code $LASTEXITCODE" +} + +Write-Host "[build-installer] Done." diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 new file mode 100644 index 00000000..175bb635 --- /dev/null +++ b/packaging/windows/build-staging.ps1 @@ -0,0 +1,301 @@ +# Build Tier-B staging directory: tools/ (uv, node, Chrome for Testing) + flocks/ (repository copy). No .venv — installer/bootstrap runs later. +# Run on Windows (PowerShell 5+). Requires: network access, Expand-Archive, robocopy (built-in). +# +# Usage: +# .\packaging\windows\build-staging.ps1 -OutputDir C:\out\flocks-staging -RepoRoot $PWD + +param( + [Parameter(Mandatory = $true)] + [string]$OutputDir, + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [string]$ManifestPath = (Join-Path $PSScriptRoot "versions.manifest.json"), + [string]$CacheRoot = "" +) + +$ErrorActionPreference = "Stop" + +function Read-Manifest { + param([string]$Path) + if (-not (Test-Path $Path)) { + throw "Manifest not found: $Path" + } + return Get-Content -Path $Path -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Ensure-EmptyDir { + param([string]$Path) + Remove-PathWithRetry -Path $Path + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Remove-PathWithRetry { + param( + [string]$Path, + [int]$MaxAttempts = 5, + [int]$DelaySeconds = 2 + ) + + if (-not (Test-Path $Path)) { + return + } + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + Remove-Item -Path $Path -Recurse -Force -ErrorAction Stop + return + } + catch { + if ($attempt -eq $MaxAttempts) { + throw + } + Write-Host "[build-staging] Failed to remove $Path (attempt $attempt/$MaxAttempts): $($_.Exception.Message)" + Start-Sleep -Seconds $DelaySeconds + } + } +} + +function Resolve-CacheRoot { + param( + [string]$RepoRoot, + [string]$CacheRootOverride + ) + + if (-not [string]::IsNullOrWhiteSpace($CacheRootOverride)) { + return $CacheRootOverride + } + if (-not [string]::IsNullOrWhiteSpace($env:FLOCKS_CACHE_ROOT)) { + return $env:FLOCKS_CACHE_ROOT + } + + $repoParent = Split-Path -Parent $RepoRoot + if (-not [string]::IsNullOrWhiteSpace($repoParent)) { + $workspaceCache = Join-Path $repoParent "flocks_deps" + if (Test-Path $workspaceCache) { + return $workspaceCache + } + } + + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + return Join-Path $env:LOCALAPPDATA "flocks\cache" + } + if (-not [string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) { + return Join-Path $env:XDG_CACHE_HOME "flocks" + } + return Join-Path $env:TEMP "flocks-cache" +} + +function Get-OrDownloadFile { + param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string]$CachePath, + [Parameter(Mandatory = $true)][string]$Label + ) + + $cacheDir = Split-Path -Parent $CachePath + if (-not (Test-Path $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + } + + if (Test-Path $CachePath) { + $existing = Get-Item -Path $CachePath + if ($existing.Length -gt 0) { + Write-Host "[build-staging] Reusing cached ${Label}: $CachePath" + return + } + Remove-PathWithRetry -Path $CachePath + } + + Write-Host "[build-staging] Downloading $Label ..." + $maxAttempts = 3 + $tmpPath = "$CachePath.download" + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + if (Test-Path $tmpPath) { + Remove-PathWithRetry -Path $tmpPath + } + try { + Invoke-WebRequest -Uri $Url -OutFile $tmpPath -UseBasicParsing + Move-Item -Path $tmpPath -Destination $CachePath -Force + return + } + catch { + if ($attempt -eq $maxAttempts) { + throw + } + Write-Host "[build-staging] Download failed for $Label (attempt $attempt/$maxAttempts): $($_.Exception.Message)" + Start-Sleep -Seconds 5 + } + } +} + +function Get-OrDownloadFileFromCandidates { + param( + [Parameter(Mandatory = $true)][string[]]$Urls, + [Parameter(Mandatory = $true)][string]$CachePath, + [Parameter(Mandatory = $true)][string]$Label + ) + + if ($Urls.Count -eq 0) { + throw "No download URL candidates provided for $Label" + } + + $lastError = $null + foreach ($url in $Urls) { + try { + Write-Host "[build-staging] Attempting $Label from: $url" + Get-OrDownloadFile -Url $url -CachePath $CachePath -Label $Label + return + } + catch { + $lastError = $_ + Write-Host "[build-staging] Candidate failed for ${Label}: $($_.Exception.Message)" + } + } + + if ($lastError) { + throw $lastError + } + throw "Failed to download $Label" +} + +Write-Host "[build-staging] RepoRoot: $RepoRoot" +Write-Host "[build-staging] OutputDir: $OutputDir" + +$manifest = Read-Manifest -Path $ManifestPath +$uvVersion = $manifest.uv.version +$nodeVersion = $manifest.nodejs.version +$nodeSuffix = $manifest.nodejs.windows_zip_suffix +$cacheRoot = Resolve-CacheRoot -RepoRoot $RepoRoot -CacheRootOverride $CacheRoot +Write-Host "[build-staging] CacheRoot: $cacheRoot" + +Ensure-EmptyDir -Path $OutputDir + +$toolsUv = Join-Path $OutputDir "tools\uv" +$toolsNode = Join-Path $OutputDir "tools\node" +$toolsChrome = Join-Path $OutputDir "tools\chrome" +$flocksDest = Join-Path $OutputDir "flocks" + +New-Item -ItemType Directory -Path $toolsUv -Force | Out-Null +New-Item -ItemType Directory -Path $toolsNode -Force | Out-Null +New-Item -ItemType Directory -Path $toolsChrome -Force | Out-Null + +# uv (standalone zip from GitHub releases) +$uvZipName = "uv-x86_64-pc-windows-msvc.zip" +$uvUrl = "https://github.com/astral-sh/uv/releases/download/$uvVersion/$uvZipName" +$uvZip = Join-Path $cacheRoot "downloads\uv-$uvVersion-$uvZipName" +Get-OrDownloadFile -Url $uvUrl -CachePath $uvZip -Label "uv $uvVersion" +Expand-Archive -Path $uvZip -DestinationPath $toolsUv -Force + +# Node.js official zip (portable) +$nodeZipName = "node-v$nodeVersion-$nodeSuffix.zip" +$nodeUrl = "https://nodejs.org/dist/v$nodeVersion/$nodeZipName" +$nodeZip = Join-Path $cacheRoot "downloads\$nodeZipName" +Get-OrDownloadFile -Url $nodeUrl -CachePath $nodeZip -Label "Node $nodeVersion" +$nodeExtract = Join-Path $env:TEMP "node-extract-$nodeVersion" +Remove-PathWithRetry -Path $nodeExtract +New-Item -ItemType Directory -Path $nodeExtract -Force | Out-Null +Expand-Archive -Path $nodeZip -DestinationPath $nodeExtract -Force +$inner = Get-ChildItem -Path $nodeExtract -Directory | Select-Object -First 1 +if (-not $inner) { + throw "Unexpected Node zip layout" +} +Copy-Item -Path (Join-Path $inner.FullName "*") -Destination $toolsNode -Recurse -Force +Remove-PathWithRetry -Path $nodeExtract + +# Chrome for Testing (bundled browser for agent-browser; prefer cached zip over npm-mediated install) +# Use the pinned version from the manifest when available (reproducible builds); fall back to LKGR. +Write-Host "[build-staging] Installing Chrome for Testing to tools\chrome (prefers cached direct download)..." +$pinnedCftVersion = $manifest.chrome_for_testing.version +$cftMirrorBase = $env:FLOCKS_CFT_MIRROR_BASE_URL +$cftUrls = @() +if (-not [string]::IsNullOrWhiteSpace($pinnedCftVersion)) { + Write-Host "[build-staging] Using pinned Chrome for Testing version: $pinnedCftVersion" + $cftVersion = $pinnedCftVersion + if (-not [string]::IsNullOrWhiteSpace($cftMirrorBase)) { + $mirrorBase = $cftMirrorBase.TrimEnd('/') + $cftUrls += "$mirrorBase/$cftVersion/win64/chrome-win64.zip" + Write-Host "[build-staging] Added mirror candidate from FLOCKS_CFT_MIRROR_BASE_URL" + } + $cftUrls += "https://storage.googleapis.com/chrome-for-testing-public/$cftVersion/win64/chrome-win64.zip" +} +else { + Write-Host "[build-staging] No pinned Chrome version in manifest — resolving via LKGR..." + $lkgrUrl = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json" + $lkgr = Invoke-WebRequest -Uri $lkgrUrl -UseBasicParsing | Select-Object -ExpandProperty Content | ConvertFrom-Json + $stable = $lkgr.channels.Stable + if (-not $stable) { + throw "Failed to resolve Stable channel from Chrome for Testing metadata" + } + $stableChrome = $stable.downloads.chrome | Where-Object { $_.platform -eq "win64" } | Select-Object -First 1 + if (-not $stableChrome) { + throw "Failed to resolve win64 download URL from Chrome for Testing metadata" + } + $cftVersion = $stable.version + if (-not [string]::IsNullOrWhiteSpace($cftMirrorBase)) { + $mirrorBase = $cftMirrorBase.TrimEnd('/') + $cftUrls += "$mirrorBase/$cftVersion/win64/chrome-win64.zip" + Write-Host "[build-staging] Added mirror candidate from FLOCKS_CFT_MIRROR_BASE_URL" + } + $cftUrls += $stableChrome.url +} +# Canonical cache name; some mirrors or manual saves use "-stable-" in the filename — reuse if present. +$dlDir = Join-Path $cacheRoot "downloads" +$cftZipPrimary = Join-Path $dlDir ("chrome-for-testing-win64-" + $cftVersion + ".zip") +$cftZipAltStable = Join-Path $dlDir ("chrome-for-testing-win64-stable-" + $cftVersion + ".zip") +if (Test-Path -LiteralPath $cftZipPrimary -PathType Leaf) { + $cftZip = $cftZipPrimary +} +elseif (Test-Path -LiteralPath $cftZipAltStable -PathType Leaf) { + $cftZip = $cftZipAltStable + Write-Host "[build-staging] Reusing cached Chrome zip (alternate filename): $cftZip" +} +else { + $cftZip = $cftZipPrimary +} +Get-OrDownloadFileFromCandidates -Urls $cftUrls -CachePath $cftZip -Label ("Chrome for Testing " + $cftVersion) + +$cftExtract = Join-Path $env:TEMP ("cft-extract-" + $cftVersion) +Remove-PathWithRetry -Path $cftExtract +New-Item -ItemType Directory -Path $cftExtract -Force | Out-Null +Expand-Archive -Path $cftZip -DestinationPath $cftExtract -Force +robocopy $cftExtract $toolsChrome /E /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +if ($LASTEXITCODE -ge 8) { + throw "robocopy failed while copying Chrome for Testing with exit code $LASTEXITCODE" +} +$global:LASTEXITCODE = 0 +Remove-PathWithRetry -Path $cftExtract + +$chromeExe = Get-ChildItem -Path $toolsChrome -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match 'chrome-win' } | + Select-Object -First 1 +if (-not $chromeExe) { + $chromeExe = Get-ChildItem -Path $toolsChrome -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | Select-Object -First 1 +} +if (-not $chromeExe) { + throw "chrome.exe not found under tools\chrome after extracting bundled Chrome for Testing" +} +$rootResolved = (Resolve-Path $OutputDir).Path +$fullChrome = $chromeExe.FullName +if (-not $fullChrome.StartsWith($rootResolved, [StringComparison]::OrdinalIgnoreCase)) { + throw "Resolved chrome.exe path is not under OutputDir" +} +$relChrome = $fullChrome.Substring($rootResolved.Length).TrimStart('\') +$hintPath = Join-Path $toolsChrome "flocks-bundled-chrome.exe.relative.txt" +Set-Content -Path $hintPath -Value $relChrome -Encoding utf8 +Write-Host "[build-staging] Recorded bundled Chrome path hint: $relChrome" + +# Copy repo (exclude heavy / irrelevant dirs, but keep project-level .flocks plugins) +$exclude = @(".git", ".venv", "node_modules") +Write-Host "[build-staging] Copying repository..." +robocopy $RepoRoot $flocksDest /E /XD $exclude /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +if ($LASTEXITCODE -ge 8) { + throw "robocopy failed with exit code $LASTEXITCODE" +} +# robocopy uses 0-7 as success states; normalize process exit code for callers. +$global:LASTEXITCODE = 0 + +# Note: the CLI wrapper ({HOME}\.local\bin\flocks.cmd) is created by +# scripts\install.ps1 during the post-install bootstrap. We deliberately do +# not pre-create an {app}\bin directory here, so the install layout stays in +# sync with the Inno shortcut targets. + +Write-Host "[build-staging] Done. Next: compile installer with flocks-setup.iss, or use build-installer.ps1 for one-step packaging." diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss new file mode 100644 index 00000000..bccc5977 --- /dev/null +++ b/packaging/windows/flocks-setup.iss @@ -0,0 +1,129 @@ +; Inno Setup 6 — install Inno Setup, then compile e.g.: +; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" packaging\windows\flocks-setup.iss /DStagingRoot=C:\path\to\staging +; StagingRoot = output directory of packaging\windows\build-staging.ps1 + +#ifndef StagingRoot + #define StagingRoot "dist\staging" +#endif + +#ifndef AppVersion + #define AppVersion "0.0.0-dev" +#endif + +#define MyAppName "Flocks" +#define MyAppVersion AppVersion +#define MyAppPublisher "Flocks" + +[Setup] +AppId={{A8C9E2F1-4B3D-5E6F-9A0B-1C2D3E4F5A6B} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={localappdata}\Programs\{#MyAppName} +DisableProgramGroupPage=yes +OutputBaseFilename=FlocksSetup +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +ChangesEnvironment=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +; Remind the user to reopen their terminal so a fresh process picks up the +; HKCU\Environment entries (FLOCKS_INSTALL_ROOT / FLOCKS_NODE_HOME / PATH) +; written during install; cmd.exe doesn't respond to WM_SETTINGCHANGE, so +; any pre-existing shells keep stale env vars. +[Messages] +FinishedLabel=Setup has finished installing [name] on your computer.%n%nHow to start Flocks:%n- Use the desktop shortcut%n- Or open a NEW terminal in the install directory and run `flocks start`%n%nPlease open a NEW terminal window first, so the updated environment variables (PATH, FLOCKS_NODE_HOME, ...) take effect.%n%n安装已完成。启动方式:%n- 使用桌面快捷方式启动%n- 或在安装目录打开新的终端后执行 `flocks start`%n%n请重新打开终端窗口,以便新的环境变量(PATH、FLOCKS_NODE_HOME 等)生效。 + +[Tasks] +Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional icons:" + +[Files] +Source: "{#StagingRoot}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Registry] +Root: HKCU; Subkey: "Environment"; ValueType: string; ValueName: "FLOCKS_INSTALL_ROOT"; ValueData: "{app}"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Environment"; ValueType: string; ValueName: "FLOCKS_REPO_ROOT"; ValueData: "{app}\flocks"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Environment"; ValueType: string; ValueName: "FLOCKS_NODE_HOME"; ValueData: "{app}\tools\node"; Flags: uninsdeletevalue + +; Shortcuts intentionally target the same wrapper path that `scripts\install.ps1` +; writes, so the Start menu / desktop icon and `flocks start` typed in a new +; terminal are strictly equivalent across all install flows. +[Icons] +Name: "{autoprograms}\{#MyAppName}\Start Flocks"; Filename: "{%USERPROFILE}\.local\bin\flocks.cmd"; Parameters: "start"; WorkingDir: "{%USERPROFILE}" +Name: "{autoprograms}\{#MyAppName}\Flocks repository"; Filename: "{app}\flocks"; WorkingDir: "{app}\flocks" +Name: "{userdesktop}\{#MyAppName}"; Filename: "{%USERPROFILE}\.local\bin\flocks.cmd"; Parameters: "start"; WorkingDir: "{%USERPROFILE}"; Tasks: desktopicon + +[Run] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\flocks\packaging\windows\bootstrap-windows.ps1"" -InstallRoot ""{app}"""; StatusMsg: "Setting up Python and JavaScript dependencies..."; Flags: runascurrentuser waituntilterminated + +; Runs before [Files] are deleted: flocks stop (graceful), then taskkill fallback, PATH/env/flocks.cmd cleanup, bundled Chrome junction. +[UninstallRun] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\flocks\packaging\windows\uninstall-flocks-user-state.ps1"" -InstallRoot ""{app}"""; RunOnceId: "FlocksUninstallCleanup"; Flags: runascurrentuser + +; Explicit shortcut removal (desktop / Start menu). Targets outside {app} may not always be tracked by the default icon uninstall. +[UninstallDelete] +Type: files; Name: "{userdesktop}\{#MyAppName}.lnk" +Type: files; Name: "{autoprograms}\{#MyAppName}\Start Flocks.lnk" +Type: files; Name: "{autoprograms}\{#MyAppName}\Flocks repository.lnk" +Type: dirifempty; Name: "{autoprograms}\{#MyAppName}" +; Remove all installed code under {app}, then remove the install root directory. +Type: filesandordirs; Name: "{app}\*" +Type: dirifempty; Name: "{app}" + +[Code] +function IsUnderBaseDir(const CandidateDir, BaseDir: string): Boolean; +var + NormalizedCandidate: string; + NormalizedBase: string; +begin + if BaseDir = '' then + begin + Result := False; + exit; + end; + + NormalizedCandidate := Lowercase(RemoveBackslashUnlessRoot(ExpandFileName(CandidateDir))); + NormalizedBase := Lowercase(RemoveBackslashUnlessRoot(ExpandFileName(BaseDir))); + + Result := + (NormalizedCandidate = NormalizedBase) or + (Pos(NormalizedBase + '\', NormalizedCandidate + '\') = 1); +end; + +function IsProgramFilesPath(const TargetDir: string): Boolean; +var + ProgramFilesDir: string; + ProgramFilesX86Dir: string; +begin + ProgramFilesDir := ExpandConstant('{%ProgramFiles}'); + ProgramFilesX86Dir := ExpandConstant('{%ProgramFiles(x86)}'); + + Result := + IsUnderBaseDir(TargetDir, ProgramFilesDir) or + IsUnderBaseDir(TargetDir, ProgramFilesX86Dir); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +var + SelectedDir: string; +begin + Result := True; + + if CurPageID <> wpSelectDir then + exit; + + SelectedDir := WizardDirValue; + if IsProgramFilesPath(SelectedDir) then + begin + MsgBox( + 'Warning: Installing under "C:\Program Files" (or Program Files (x86)) may require Administrator privileges when running or updating Flocks.' + #13#10 + #13#10 + + '警告:安装到“C:\Program Files”(或 Program Files (x86))目录后,运行或更新 Flocks 可能需要管理员权限。', + mbInformation, + MB_OK + ); + end; +end; diff --git a/packaging/windows/staging-layout.json b/packaging/windows/staging-layout.json new file mode 100644 index 00000000..2b76187b --- /dev/null +++ b/packaging/windows/staging-layout.json @@ -0,0 +1,17 @@ +{ + "description": "Directory layout for Tier-B Windows installer staging (tools + flocks repo root, no prebuilt .venv).", + "install_root_children": [ + "bin", + "tools/uv", + "tools/node", + "tools/chrome (Chrome for Testing from build-staging.ps1; includes flocks-bundled-chrome.exe.relative.txt)", + "tools/npm-global", + "flocks" + ], + "environment": { + "FLOCKS_INSTALL_ROOT": "Absolute path to install root (contains bin/, tools/, flocks/). Optional; used to derive defaults.", + "FLOCKS_REPO_ROOT": "Absolute path to repository root = {install_root}/flocks (contains pyproject.toml).", + "FLOCKS_NODE_HOME": "Directory containing node.exe (typically {install_root}/tools/node). Overrides PATH order for WebUI.", + "FLOCKS_ROOT": "User data directory (logs, run, workspace outputs); set by installer wizard." + } +} diff --git a/packaging/windows/uninstall-flocks-user-state.ps1 b/packaging/windows/uninstall-flocks-user-state.ps1 new file mode 100644 index 00000000..835c608f --- /dev/null +++ b/packaging/windows/uninstall-flocks-user-state.ps1 @@ -0,0 +1,372 @@ +# Called by Inno Setup [UninstallRun] before application files are removed. +# Cleans User PATH (any segment under {app}), global flocks.cmd wrapper, optional env vars, shortcuts, bundled Chrome junction. +# Does NOT delete %USERPROFILE%\.flocks (user data — logs, workspace, etc.). +# UTF-8 with BOM (Windows PowerShell 5.1) + +param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot +) + +$ErrorActionPreference = "Stop" + +function Write-UninstallLog { + param([string]$Message) + Write-Host "[flocks-uninstall] $Message" +} + +function Remove-UserPathSegmentsUnderInstallRoot { + param([string]$Root) + + # Removes every User PATH segment that is exactly the install root or any subdirectory + # (e.g. ...\Flocks\bin, ...\Flocks\tools\uv, ...\Flocks\tools\node). Does not touch + # %USERPROFILE%\.local\bin or other paths outside {app}. + $Root = $Root.TrimEnd('\', '/') + if ([string]::IsNullOrWhiteSpace($Root)) { + return + } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ([string]::IsNullOrWhiteSpace($userPath)) { + return + } + + $parts = $userPath -split ";" | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $kept = New-Object System.Collections.Generic.List[string] + foreach ($p in $parts) { + $norm = $p.TrimEnd('\', '/') + $underRoot = $false + if ($norm.Equals($Root, [StringComparison]::OrdinalIgnoreCase)) { + $underRoot = $true + } + elseif ($norm.Length -gt $Root.Length -and $norm.StartsWith($Root + '\', [StringComparison]::OrdinalIgnoreCase)) { + $underRoot = $true + } + + if (-not $underRoot) { + [void]$kept.Add($p) + } + } + + $newPath = ($kept.ToArray()) -join ";" + if ($userPath -eq $newPath) { + return + } + + if ([string]::IsNullOrEmpty($newPath)) { + [Environment]::SetEnvironmentVariable("Path", $null, "User") + } + else { + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + } + Write-UninstallLog 'Updated User PATH (removed all entries under the Flocks install directory).' +} + +function Remove-UserEnvIfValue { + param( + [string]$Name, + [string]$ExpectedValue + ) + + if ([string]::IsNullOrWhiteSpace($ExpectedValue)) { + return + } + + $cur = [Environment]::GetEnvironmentVariable($Name, "User") + if ([string]::IsNullOrWhiteSpace($cur)) { + return + } + + if ($cur -eq $ExpectedValue) { + [Environment]::SetEnvironmentVariable($Name, $null, "User") + Write-UninstallLog "Removed User env: $Name" + } +} + +function Invoke-FlocksStop { + param([string]$Root) + + $venvPy = Join-Path $Root "flocks\.venv\Scripts\python.exe" + if (Test-Path -LiteralPath $venvPy) { + Write-UninstallLog "Running flocks stop (via install directory venv)..." + try { + $prevEa = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $venvPy -m flocks.cli.main stop 2>&1 | ForEach-Object { Write-Host $_ } + $ErrorActionPreference = $prevEa + Write-UninstallLog "flocks stop finished (exit code: $LASTEXITCODE)." + } + catch { + Write-UninstallLog "flocks stop raised: $($_.Exception.Message)" + } + Start-Sleep -Seconds 2 + return + } + + $flocksCmd = Get-Command flocks -ErrorAction SilentlyContinue + if ($flocksCmd) { + Write-UninstallLog "Running flocks stop (via PATH: $($flocksCmd.Source))..." + try { + $prevEa = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $flocksCmd.Source stop 2>&1 | ForEach-Object { Write-Host $_ } + $ErrorActionPreference = $prevEa + Write-UninstallLog "flocks stop finished (exit code: $LASTEXITCODE)." + } + catch { + Write-UninstallLog "flocks stop raised: $($_.Exception.Message)" + } + Start-Sleep -Seconds 2 + } + else { + Write-UninstallLog "Skipping flocks stop: no venv at $venvPy and no flocks on PATH." + } +} + +function Stop-FlocksFromRuntimePidFiles { + $runDir = Join-Path $HOME ".flocks\run" + foreach ($name in @("backend.pid", "webui.pid")) { + $f = Join-Path $runDir $name + if (-not (Test-Path -LiteralPath $f)) { + continue + } + + try { + $text = Get-Content -LiteralPath $f -Raw -Encoding UTF8 + $m = [regex]::Match($text, '"pid"\s*:\s*(\d+)') + if (-not $m.Success) { + continue + } + + $processId = [int]$m.Groups[1].Value + if ($processId -le 0) { + continue + } + + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + if (-not $proc) { + continue + } + + Write-UninstallLog "Stopping PID $processId (from $name) and child processes..." + & taskkill.exe /PID $processId /T /F | Out-Null + } + catch { + Write-UninstallLog "Could not stop from ${name}: $($_.Exception.Message)" + } + } +} + +function Stop-ProcessesUsingInstallRoot { + param([string]$Root) + + $Root = $Root.TrimEnd('\', '/') + if ([string]::IsNullOrWhiteSpace($Root)) { + return + } + + $escaped = [Regex]::Escape($Root) + $parentPid = $null + try { + $self = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $PID) -ErrorAction SilentlyContinue + if ($self) { + $parentPid = [int]$self.ParentProcessId + } + } + catch { } + + try { + $procs = Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { + if ([int]$_.ProcessId -eq $PID) { + return $false + } + if ($null -ne $parentPid -and [int]$_.ProcessId -eq $parentPid) { + return $false + } + $name = $_.Name + if (-not [string]::IsNullOrWhiteSpace($name) -and $name -match '^unins\d+\.exe$') { + return $false + } + $cmd = $_.CommandLine + if ([string]::IsNullOrWhiteSpace($cmd)) { + return $false + } + if ($cmd -match '\\unins\d+\.exe(\s|")') { + return $false + } + return [Regex]::IsMatch($cmd, $escaped, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + } + + foreach ($p in $procs) { + try { + Write-UninstallLog "Force-stopping PID $($p.ProcessId) referencing install root..." + & taskkill.exe /PID $p.ProcessId /T /F | Out-Null + } + catch { + Write-UninstallLog "Could not force-stop PID $($p.ProcessId): $($_.Exception.Message)" + } + } + } + catch { + Write-UninstallLog "Process sweep by install root failed: $($_.Exception.Message)" + } +} + +function Remove-LocalBinFlocksWrappers { + param([string]$Root) + + $localBin = Join-Path $HOME ".local\bin" + if (-not (Test-Path -LiteralPath $localBin)) { + return + } + + foreach ($fn in @("flocks.cmd", "flocks.exe", "flocks.exe.bak")) { + $p = Join-Path $localBin $fn + if (-not (Test-Path -LiteralPath $p)) { + continue + } + + try { + $raw = Get-Content -LiteralPath $p -Raw -Encoding Default -ErrorAction Stop + if ($raw -and $raw.IndexOf($Root, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + Remove-Item -LiteralPath $p -Force -ErrorAction Stop + Write-UninstallLog "Removed $p" + } + } + catch { + Write-UninstallLog "Could not remove ${fn}: $($_.Exception.Message)" + } + } +} + +function Remove-FlocksShellShortcuts { + # Matches [Icons] in flocks-setup.iss (user desktop + Start menu). Shell folder APIs follow OneDrive desktop redirects. + $candidates = New-Object System.Collections.Generic.List[string] + + try { + $desk = [Environment]::GetFolderPath('Desktop') + if (-not [string]::IsNullOrWhiteSpace($desk)) { + [void]$candidates.Add((Join-Path $desk 'Flocks.lnk')) + } + } + catch { } + + try { + $programs = [Environment]::GetFolderPath('Programs') + if (-not [string]::IsNullOrWhiteSpace($programs)) { + $flocksProg = Join-Path $programs 'Flocks' + [void]$candidates.Add((Join-Path $flocksProg 'Start Flocks.lnk')) + [void]$candidates.Add((Join-Path $flocksProg 'Flocks repository.lnk')) + } + } + catch { } + + foreach ($p in $candidates) { + if ([string]::IsNullOrWhiteSpace($p) -or -not (Test-Path -LiteralPath $p)) { + continue + } + try { + Remove-Item -LiteralPath $p -Force -ErrorAction Stop + Write-UninstallLog "Removed shortcut: $p" + } + catch { + Write-UninstallLog "Could not remove shortcut ${p}: $($_.Exception.Message)" + } + } + + try { + $programs = [Environment]::GetFolderPath('Programs') + if (-not [string]::IsNullOrWhiteSpace($programs)) { + $flocksProg = Join-Path $programs 'Flocks' + if (Test-Path -LiteralPath $flocksProg) { + $left = @(Get-ChildItem -LiteralPath $flocksProg -Force -ErrorAction SilentlyContinue) + if ($left.Count -eq 0) { + Remove-Item -LiteralPath $flocksProg -Force -ErrorAction SilentlyContinue + Write-UninstallLog "Removed empty Start menu folder: $flocksProg" + } + } + } + } + catch { } +} + +function Remove-BundledBrowserJunction { + param([string]$Root) + + $bundled = Join-Path $HOME ".flocks\browser\bundled" + if (-not (Test-Path -LiteralPath $bundled)) { + return + } + + try { + $expectedChrome = (Join-Path $Root "tools\chrome").TrimEnd('\', '/') + $out = cmd /c "fsutil reparsepoint query `"$bundled`" 2>nul" | Out-String + if ($out -and $out.IndexOf($expectedChrome, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + cmd /c "rmdir `"$bundled`"" | Out-Null + Write-UninstallLog "Removed junction $bundled" + } + } + catch { + Write-UninstallLog "Bundled junction cleanup skipped: $($_.Exception.Message)" + } +} + +function Test-PathEqualsOrUnderRoot { + param( + [string]$PathValue, + [string]$Root + ) + + if ([string]::IsNullOrWhiteSpace($PathValue) -or [string]::IsNullOrWhiteSpace($Root)) { + return $false + } + + $normPath = $PathValue.Trim().Trim('"').TrimEnd('\', '/') + $normRoot = $Root.TrimEnd('\', '/') + if ([string]::IsNullOrWhiteSpace($normPath) -or [string]::IsNullOrWhiteSpace($normRoot)) { + return $false + } + + if ($normPath.Equals($normRoot, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + + if ($normPath.Length -gt $normRoot.Length -and $normPath.StartsWith($normRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + + return $false +} + +try { + $InstallRoot = $InstallRoot.TrimEnd('\', '/') + Write-UninstallLog "Cleaning user state for: $InstallRoot" + + Invoke-FlocksStop -Root $InstallRoot + Stop-FlocksFromRuntimePidFiles + Stop-ProcessesUsingInstallRoot -Root $InstallRoot + + Remove-UserPathSegmentsUnderInstallRoot -Root $InstallRoot + + $repoRoot = Join-Path $InstallRoot "flocks" + $nodeHome = Join-Path $InstallRoot "tools\node" + Remove-UserEnvIfValue -Name "FLOCKS_INSTALL_ROOT" -ExpectedValue $InstallRoot + Remove-UserEnvIfValue -Name "FLOCKS_REPO_ROOT" -ExpectedValue $repoRoot + Remove-UserEnvIfValue -Name "FLOCKS_NODE_HOME" -ExpectedValue $nodeHome + + $agent = [Environment]::GetEnvironmentVariable("AGENT_BROWSER_EXECUTABLE_PATH", "User") + if (Test-PathEqualsOrUnderRoot -PathValue $agent -Root $InstallRoot) { + [Environment]::SetEnvironmentVariable("AGENT_BROWSER_EXECUTABLE_PATH", $null, "User") + Write-UninstallLog "Cleared AGENT_BROWSER_EXECUTABLE_PATH (pointed to current install root)." + } + + Remove-LocalBinFlocksWrappers -Root $InstallRoot + Remove-BundledBrowserJunction -Root $InstallRoot + Remove-FlocksShellShortcuts +} +catch { + Write-UninstallLog "ERROR: $($_.Exception.Message)" +} + +# Never fail the Inno uninstaller (cleanup best-effort). +exit 0 diff --git a/packaging/windows/versions.manifest.json b/packaging/windows/versions.manifest.json new file mode 100644 index 00000000..1d420759 --- /dev/null +++ b/packaging/windows/versions.manifest.json @@ -0,0 +1,14 @@ +{ + "description": "Pinned versions for Windows bundled staging (CI downloads + local packaging). Bump when upgrading toolchains.", + "uv": { + "version": "0.9.15" + }, + "nodejs": { + "version": "24.14.0", + "windows_zip_suffix": "win-x64" + }, + "chrome_for_testing": { + "version": "147.0.7727.57", + "channel": "stable" + } +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3da466a7..45acafc1 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -75,6 +75,10 @@ function Test-IsAdministrator { } function Assert-Administrator { + if ($env:FLOCKS_SKIP_ADMIN_CHECK -eq "1") { + return + } + if (Test-IsAdministrator) { return } diff --git a/scripts/install_zh.ps1 b/scripts/install_zh.ps1 index 02b03de9..7fccf18a 100644 --- a/scripts/install_zh.ps1 +++ b/scripts/install_zh.ps1 @@ -27,6 +27,10 @@ function Test-IsAdministrator { } function Assert-Administrator { + if ($env:FLOCKS_SKIP_ADMIN_CHECK -eq "1") { + return + } + if (Test-IsAdministrator) { return } diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index dc60fd58..9302ffba 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -1,6 +1,7 @@ import contextlib import json import signal +import sys from pathlib import Path from types import SimpleNamespace @@ -29,6 +30,133 @@ def test_runtime_paths_follow_flocks_root_env(monkeypatch, tmp_path: Path) -> No assert paths.frontend_log == tmp_path / "logs" / "webui.log" +def test_resolve_node_executable_prefers_flocks_node_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + home = tmp_path / "nh" + home.mkdir() + if sys.platform == "win32": + (home / "node.exe").write_bytes(b"") + else: + (home / "bin").mkdir() + (home / "bin" / "node").write_bytes(b"") + monkeypatch.setenv("FLOCKS_NODE_HOME", str(home)) + + resolved = service_manager.resolve_node_executable() + assert resolved is not None + assert Path(resolved).name in ("node", "node.exe") + + +def test_resolve_node_executable_prefers_flocks_install_root_tools_node( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.delenv("FLOCKS_NODE_HOME", raising=False) + root = tmp_path / "inst" + node_home = root / "tools" / "node" + node_home.mkdir(parents=True) + if sys.platform == "win32": + (node_home / "node.exe").write_bytes(b"") + else: + (node_home / "bin").mkdir(parents=True) + (node_home / "bin" / "node").write_bytes(b"") + monkeypatch.setenv("FLOCKS_INSTALL_ROOT", str(root)) + + resolved = service_manager.resolve_node_executable() + assert resolved is not None + assert Path(resolved).name in ("node", "node.exe") + + +def test_resolve_node_executable_falls_back_to_which_when_env_absent( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.delenv("FLOCKS_NODE_HOME", raising=False) + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + monkeypatch.setattr(service_manager, "which", lambda name: "/usr/bin/node" if name == "node" else None) + + assert service_manager.resolve_node_executable() == "/usr/bin/node" + + +def test_resolve_node_executable_falls_back_to_which_when_path_missing( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + monkeypatch.setenv("FLOCKS_NODE_HOME", str(tmp_path / "nonexistent")) + monkeypatch.setattr(service_manager, "which", lambda name: "/usr/bin/node" if name == "node" else None) + + assert service_manager.resolve_node_executable() == "/usr/bin/node" + + +def test_resolve_npm_executable_prefers_flocks_node_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + home = tmp_path / "nh" + home.mkdir() + if sys.platform == "win32": + (home / "node.exe").write_bytes(b"") + bundled_npm = home / "npm.cmd" + else: + (home / "bin").mkdir() + (home / "bin" / "node").write_bytes(b"") + bundled_npm = home / "bin" / "npm" + bundled_npm.write_bytes(b"") + monkeypatch.setenv("FLOCKS_NODE_HOME", str(home)) + monkeypatch.setattr(service_manager, "which", lambda name: "/usr/bin/npm") + + resolved = service_manager.resolve_npm_executable() + + assert resolved == str(bundled_npm) + + +def test_resolve_npm_executable_falls_back_to_which(monkeypatch, tmp_path: Path) -> None: + monkeypatch.delenv("FLOCKS_NODE_HOME", raising=False) + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + monkeypatch.setattr(service_manager.sys, "platform", "win32") + + def fake_which(name: str) -> str | None: + if name == "npm.cmd": + return r"C:\Program Files\nodejs\npm.cmd" + return None + + monkeypatch.setattr(service_manager, "which", fake_which) + + assert service_manager.resolve_npm_executable() == r"C:\Program Files\nodejs\npm.cmd" + + +def test_build_frontend_env_prepends_bundled_node_to_path( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + node_home = tmp_path / "tools" / "node" + if sys.platform == "win32": + node_home.mkdir(parents=True) + (node_home / "node.exe").write_bytes(b"") + else: + (node_home / "bin").mkdir(parents=True) + (node_home / "bin" / "node").write_bytes(b"") + monkeypatch.setenv("FLOCKS_NODE_HOME", str(node_home)) + + config = service_manager.ServiceConfig(backend_host="127.0.0.1", backend_port=8000) + env = service_manager.build_frontend_env(config) + + path_entries = env["PATH"].split(service_manager.os.pathsep) + if sys.platform == "win32": + assert path_entries[0] == str(node_home) + else: + assert path_entries[0] == str(node_home / "bin") + + +def test_build_frontend_env_no_path_injection_without_bundled_node( + monkeypatch, +) -> None: + monkeypatch.delenv("FLOCKS_NODE_HOME", raising=False) + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + + import os as _os + original_path = _os.environ.get("PATH", "") + config = service_manager.ServiceConfig(backend_host="127.0.0.1", backend_port=8000) + env = service_manager.build_frontend_env(config) + + assert env["PATH"] == original_path + + def test_cleanup_stale_pid_file_removes_dead_pid(tmp_path: Path) -> None: pid_file = tmp_path / "backend.pid" pid_file.write_text("999999", encoding="utf-8") @@ -479,71 +607,6 @@ def test_restart_all_stops_then_starts_under_lock(monkeypatch) -> None: ] -def test_start_all_uses_config_backend_port_when_pid_record_missing(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - config = service_manager.ServiceConfig(frontend_port=5175, backend_port=9100) - calls: list[tuple[int, Path, str]] = [] - - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) - monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - service_manager, - "stop_one", - lambda port, pid_file, name, _console: calls.append((port, pid_file, name)), - ) - monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda *_args, **_kwargs: None) - - service_manager.start_all(config, console=None) - - assert calls == [ - (5175, paths.frontend_pid, "WebUI"), - (9100, paths.backend_pid, "后端"), - ] - - -def test_restart_all_uses_config_backend_port_with_legacy_pid_record(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.backend_pid.write_text("12345", encoding="utf-8") - config = service_manager.ServiceConfig(frontend_port=5176, backend_port=9200) - calls: list[tuple[int, Path, str]] = [] - - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) - monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", lambda *_args, **_kwargs: None) - monkeypatch.setattr( - service_manager, - "stop_one", - lambda port, pid_file, name, _console: calls.append((port, pid_file, name)), - ) - monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda *_args, **_kwargs: None) - - service_manager.restart_all(config, console=None) - - assert calls == [ - (5176, paths.frontend_pid, "WebUI"), - (9200, paths.backend_pid, "后端"), - ] - - def test_start_all_stops_on_failure_before_restart(monkeypatch) -> None: paths = service_manager.RuntimePaths( root=Path("/tmp"), @@ -714,7 +777,7 @@ def fake_spawn(command, **kwargs): monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: pid) - monkeypatch.setattr(service_manager, "which", lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else None) + monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "/usr/bin/npm") monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) monkeypatch.setattr(service_manager.subprocess, "run", fake_run) monkeypatch.setattr(service_manager, "_spawn_process", fake_spawn) @@ -752,6 +815,41 @@ def fake_spawn(command, **kwargs): assert record.port == 5174 +def test_start_frontend_prefers_bundled_npm_over_path_lookup(monkeypatch, tmp_path: Path) -> None: + paths = service_manager.RuntimePaths( + root=tmp_path, + run_dir=tmp_path / "run", + log_dir=tmp_path / "logs", + backend_pid=tmp_path / "run" / "backend.pid", + frontend_pid=tmp_path / "run" / "webui.pid", + backend_log=tmp_path / "logs" / "backend.log", + frontend_log=tmp_path / "logs" / "webui.log", + ) + paths.run_dir.mkdir(parents=True) + paths.log_dir.mkdir(parents=True) + console = DummyConsole() + build_calls: list[list[str]] = [] + + def fake_run(command, **_kwargs): + build_calls.append(command) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) + monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd") + monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) + monkeypatch.setattr(service_manager.subprocess, "run", fake_run) + monkeypatch.setattr(service_manager, "_spawn_process", lambda *_args, **_kwargs: SimpleNamespace(pid=2468)) + + service_manager.start_frontend(service_manager.ServiceConfig(), console) + + assert build_calls[0][0] == r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd" + + def test_start_backend_raises_on_port_record_mismatch(monkeypatch, tmp_path: Path) -> None: paths = service_manager.RuntimePaths( root=tmp_path, diff --git a/tests/packaging/test_windows_manifest.py b/tests/packaging/test_windows_manifest.py new file mode 100644 index 00000000..78aea3d1 --- /dev/null +++ b/tests/packaging/test_windows_manifest.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +WINDOWS_MANIFEST = REPO_ROOT / "packaging" / "windows" / "versions.manifest.json" + + +def _parse_version(value: str) -> tuple[int, ...]: + return tuple(int(part) for part in value.split(".")) + + +def test_windows_bundled_uv_supports_python_downloads_json_url() -> None: + manifest = json.loads(WINDOWS_MANIFEST.read_text(encoding="utf-8")) + + assert _parse_version(manifest["uv"]["version"]) >= (0, 7, 3) diff --git a/tests/scripts/test_browser_runtime_configuration.py b/tests/scripts/test_browser_runtime_configuration.py index 0c467f35..138e8fbf 100644 --- a/tests/scripts/test_browser_runtime_configuration.py +++ b/tests/scripts/test_browser_runtime_configuration.py @@ -2,6 +2,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT_DIR = REPO_ROOT / "scripts" +PACKAGING_WINDOWS_DIR = REPO_ROOT / "packaging" / "windows" def test_bash_installer_prefers_explicit_browser_configuration() -> None: @@ -49,3 +50,82 @@ def test_powershell_installer_prefers_explicit_browser_configuration() -> None: assert 'Found existing Chrome for Testing. agent-browser will use: $browserPath' in script assert "agent-browser install" not in script assert 'require("@puppeteer/browsers")' not in script + + +def test_powershell_installer_is_bundled_unaware() -> None: + """install.ps1 must not branch on FLOCKS_INSTALL_ROOT — bundled glue lives in packaging/windows/bootstrap-windows.ps1.""" + script = (SCRIPT_DIR / "install.ps1").read_text(encoding="utf-8-sig") + + # Previous iteration embedded bundled-aware helpers in install.ps1; they must not return. + assert "Resolve-BundledChromePath" not in script + assert "flocks-bundled-chrome.exe.relative.txt" not in script + assert "FLOCKS_INSTALL_ROOT" not in script + + +def test_powershell_bootstrap_wires_bundled_toolchain() -> None: + """packaging/windows/bootstrap-windows.ps1 is the single place that bridges the bundled layout to install.ps1.""" + script = (PACKAGING_WINDOWS_DIR / "bootstrap-windows.ps1").read_text(encoding="utf-8-sig") + + assert "FLOCKS_SKIP_ADMIN_CHECK" in script + assert "tools\\uv" in script + assert "tools\\node" in script + assert "tools\\chrome" in script + assert ".flocks\\browser" in script + assert "mklink /J" in script + assert 'scripts\\install_zh.ps1' in script + + +def test_inno_setup_points_to_packaging_bootstrap() -> None: + """flocks-setup.iss must invoke the bootstrap from its new packaging location.""" + iss = (PACKAGING_WINDOWS_DIR / "flocks-setup.iss").read_text(encoding="utf-8") + + assert "packaging\\windows\\bootstrap-windows.ps1" in iss + assert "scripts\\bootstrap-windows.ps1" not in iss + + +def test_inno_shortcuts_point_to_user_local_bin_wrapper() -> None: + """Start-menu and desktop shortcuts must match the CLI wrapper location that + `scripts/install.ps1` writes, so `flocks start` triggered from the shortcut + and from a freshly opened terminal are strictly equivalent across all + install flows (source, one-liner, bundled installer).""" + iss = (PACKAGING_WINDOWS_DIR / "flocks-setup.iss").read_text(encoding="utf-8") + + icons_section_idx = iss.find("[Icons]") + run_section_idx = iss.find("[Run]", icons_section_idx) + assert icons_section_idx != -1 and run_section_idx != -1 + icons_block = iss[icons_section_idx:run_section_idx] + + expected_target = "{%USERPROFILE}\\.local\\bin\\flocks.cmd" + start_menu_lines = [ + line + for line in icons_block.splitlines() + if "Start Flocks" in line or "{userdesktop}" in line + ] + assert start_menu_lines, "expected Start Flocks + desktop shortcut entries" + for line in start_menu_lines: + assert expected_target in line, ( + f"shortcut must target the shared wrapper path; got: {line}" + ) + assert 'Parameters: "start"' in line + + # Guard against accidentally re-introducing a shortcut to {app}\bin, which + # would point to a non-existent file because install.ps1 writes the wrapper + # under %USERPROFILE%\.local\bin. + assert "{app}\\bin\\flocks.cmd" not in icons_block + + +def test_inno_finish_page_reminds_user_to_reopen_terminal() -> None: + """The finish page must tell the user to open a NEW terminal, because cmd.exe + does not respond to WM_SETTINGCHANGE and pre-existing shells would otherwise + run `flocks start` with stale env vars (no FLOCKS_NODE_HOME / updated PATH).""" + iss = (PACKAGING_WINDOWS_DIR / "flocks-setup.iss").read_text(encoding="utf-8") + + messages_idx = iss.find("[Messages]") + assert messages_idx != -1, "expected [Messages] section with reopen-terminal hint" + messages_block = iss[messages_idx:] + + assert "FinishedLabel=" in messages_block + # Bilingual hint (English + 中文) so both locales see it. + assert "NEW terminal" in messages_block + assert "请重新打开终端" in messages_block + assert "flocks start" in messages_block diff --git a/tests/updater/test_updater.py b/tests/updater/test_updater.py index 33fa9762..707a8ca2 100644 --- a/tests/updater/test_updater.py +++ b/tests/updater/test_updater.py @@ -137,6 +137,41 @@ def test_find_executable_checks_windows_cmd_suffixes( assert updater._find_executable("npm") == str(npm_cmd) +def test_resolve_npm_executable_prefers_bundled_node_home_on_windows( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + node_home = tmp_path / "tools" / "node" + node_home.mkdir(parents=True) + (node_home / "node.exe").write_text("", encoding="utf-8") + bundled_npm = node_home / "npm.cmd" + bundled_npm.write_text("", encoding="utf-8") + + monkeypatch.setattr(updater.sys, "platform", "win32") + monkeypatch.setenv("FLOCKS_NODE_HOME", str(node_home)) + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + monkeypatch.setattr(updater, "_find_executable", lambda _name: r"C:\Program Files\nodejs\npm.cmd") + + assert updater._resolve_npm_executable() == str(bundled_npm) + + +def test_resolve_npm_executable_falls_back_to_find_executable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(updater.sys, "platform", "win32") + monkeypatch.delenv("FLOCKS_NODE_HOME", raising=False) + monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) + + def fake_find(name: str) -> str | None: + if name == "npm.cmd": + return r"C:\Program Files\nodejs\npm.cmd" + return None + + monkeypatch.setattr(updater, "_find_executable", fake_find) + + assert updater._resolve_npm_executable() == r"C:\Program Files\nodejs\npm.cmd" + + def test_find_executable_ignores_wsl_mnt_paths( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -1400,6 +1435,68 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): ] +@pytest.mark.asyncio +async def test_perform_update_prefers_bundled_npm_for_windows_frontend_rebuild( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_path = tmp_path / "flocks.zip" + archive_path.write_text("archive", encoding="utf-8") + staged_root = tmp_path / "staged" + staged_webui = staged_root / "webui" + staged_webui.mkdir(parents=True) + (staged_webui / "package.json").write_text("{}", encoding="utf-8") + (staged_webui / "dist").mkdir() + (staged_webui / "dist" / "index.html").write_text("", encoding="utf-8") + + run_calls: list[list[str]] = [] + + async def fake_get_updater_config(): + return SimpleNamespace( + archive_format="zip", + sources=["github"], + repo="AgentFlocks/Flocks", + token=None, + gitee_token=None, + backup_retain_count=3, + base_url=None, + gitee_repo=None, + ) + + async def fake_download_with_fallback(**_kwargs): + return archive_path + + async def fake_run_async(cmd, cwd=None, timeout=None, env=None): + run_calls.append(list(cmd)) + return 0, "", "" + + async def fake_validate_windows_restart_runtime(*_args, **_kwargs): + return None + + monkeypatch.setattr(updater, "_get_updater_config", fake_get_updater_config) + monkeypatch.setattr(updater, "_get_repo_root", lambda: tmp_path / "install-root") + monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31") + monkeypatch.setattr(updater, "_download_with_fallback", fake_download_with_fallback) + monkeypatch.setattr(updater, "_backup_current_version", lambda *_args, **_kwargs: tmp_path / "backup.tar.gz") + monkeypatch.setattr(updater, "_extract_archive", lambda *_args, **_kwargs: staged_root) + monkeypatch.setattr(updater, "_run_async", fake_run_async) + monkeypatch.setattr(updater, "_resolve_npm_executable", lambda: r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd") + monkeypatch.setattr(updater, "_find_executable", lambda name: r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\uv\uv.exe" if name == "uv" else None) + monkeypatch.setattr(updater, "_build_uv_sync_env", lambda: None) + monkeypatch.setattr(updater, "_validate_windows_restart_runtime", fake_validate_windows_restart_runtime) + monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None) + monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) + monkeypatch.setattr(updater.sys, "platform", "win32") + + progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] + + assert progresses[-1].stage == "done" + assert run_calls[:2] == [ + [r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd", "install"], + [r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd", "run", "build"], + ] + + @pytest.mark.asyncio async def test_perform_update_errors_when_uv_not_found( monkeypatch: pytest.MonkeyPatch,