From 2672fd97b8c46cad87619531ab951bbc8f0d46a3 Mon Sep 17 00:00:00 2001 From: chenjie Date: Wed, 15 Apr 2026 13:11:16 +0800 Subject: [PATCH 01/17] debug --- packaging/windows/build-staging.ps1 | 4 +- scripts/install.ps1 | 152 +++------------------------- 2 files changed, 15 insertions(+), 141 deletions(-) diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index 8c5290be..af401d50 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -1,4 +1,4 @@ -# Build Tier-B staging directory: tools/ (uv, node, Chrome for Testing) + flocks/ (repository copy). No .venv — user runs bootstrap later. +# Build Tier-B staging directory: tools/ (uv, node, Chrome for Testing) + flocks/ (repository copy). No .venv — user runs bootstrap later. # Run on Windows (PowerShell 5+). Requires: network access, Expand-Archive, robocopy (built-in). # # Usage: @@ -90,7 +90,7 @@ if (-not [string]::IsNullOrWhiteSpace($env:PUPPETEER_CHROME_DOWNLOAD_BASE_URL)) $puppeteerEnv["PUPPETEER_CHROME_DOWNLOAD_BASE_URL"] = $env:PUPPETEER_CHROME_DOWNLOAD_BASE_URL } try { - $cfTResult = & $npxCmd @("--yes", "@puppeteer/browsers", "install", "chrome@stable", "--path", $toolsChrome) 2>&1 + $cfTResult = & $npxCmd "--yes" "@puppeteer/browsers" "install" "chrome@stable" "--path" $toolsChrome 2>&1 $cfTResult | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -ne 0) { throw "Chrome for Testing install exited with code $LASTEXITCODE" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d8e8da3b..3da466a7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -9,7 +9,6 @@ $RepoUrl = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_INSTALL_REPO_URL)) { "ht $RawInstallShUrl = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_RAW_INSTALL_SH_URL)) { "https://raw.githubusercontent.com/AgentFlocks/Flocks/main/install.sh" } else { $env:FLOCKS_RAW_INSTALL_SH_URL } $RawInstallPs1Url = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_RAW_INSTALL_PS1_URL)) { "https://raw.githubusercontent.com/AgentFlocks/Flocks/main/install.ps1" } else { $env:FLOCKS_RAW_INSTALL_PS1_URL } $RootDir = $null -$script:BundledInstallRoot = $null $MinNodeMajor = 22 $script:InstallLanguage = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_INSTALL_LANGUAGE)) { "en" } else { $env:FLOCKS_INSTALL_LANGUAGE } $script:UvDefaultIndex = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_UV_DEFAULT_INDEX)) { "https://pypi.org/simple" } else { $env:FLOCKS_UV_DEFAULT_INDEX } @@ -96,43 +95,6 @@ function Initialize-InstallSources { } } -function Initialize-BundledToolchainIfPresent { - $installRoot = $env:FLOCKS_INSTALL_ROOT - if ([string]::IsNullOrWhiteSpace($installRoot)) { - return - } - - $installRoot = $installRoot.TrimEnd('\', '/') - $uvExe = Join-Path $installRoot "tools\uv\uv.exe" - $nodeExe = Join-Path $installRoot "tools\node\node.exe" - if (-not (Test-Path $uvExe) -or -not (Test-Path $nodeExe)) { - return - } - - $script:BundledInstallRoot = $installRoot - $uvBin = Split-Path -Parent $uvExe - $nodeBin = Split-Path -Parent $nodeExe - $npmPrefix = Join-Path $installRoot "tools\npm-global" - $npmBin = Join-Path $npmPrefix "node_modules\.bin" - - Add-PathEntry $uvBin - Add-PathEntry $nodeBin - if (Test-Path $npmBin) { - Add-PathEntry $npmBin - } - - $env:FLOCKS_NODE_HOME = $nodeBin - [Environment]::SetEnvironmentVariable("FLOCKS_NODE_HOME", $nodeBin, "Process") - - $repoGuess = Join-Path $installRoot "flocks" - if ([string]::IsNullOrWhiteSpace($env:FLOCKS_REPO_ROOT) -and (Test-Path (Join-Path $repoGuess "pyproject.toml"))) { - $env:FLOCKS_REPO_ROOT = $repoGuess - [Environment]::SetEnvironmentVariable("FLOCKS_REPO_ROOT", $repoGuess, "Process") - } - - Write-Info "Using bundled toolchain under: $installRoot" -} - function Get-NodeMajorVersion { if (-not (Test-Command "node")) { return $null @@ -895,12 +857,7 @@ function Invoke-InstallerCommandWithLockRetry { function Install-FlocksCli { Write-Info "Installing the global flocks CLI..." - $linkDir = if ($script:BundledInstallRoot) { - Join-Path $script:BundledInstallRoot "bin" - } - else { - Join-Path $HOME ".local\bin" - } + $linkDir = Join-Path $HOME ".local\bin" if (Test-Command "uv") { $savedEA = $ErrorActionPreference @@ -983,44 +940,6 @@ function Get-CommandPath { return $null } -function Find-BundledChromePath { - if ([string]::IsNullOrWhiteSpace($script:BundledInstallRoot)) { - return $null - } - - $hintFile = Join-Path $script:BundledInstallRoot "tools\chrome\flocks-bundled-chrome.exe.relative.txt" - if (Test-Path $hintFile) { - try { - $rel = (Get-Content -Path $hintFile -Raw -Encoding UTF8).Trim() - } - catch { - $rel = (Get-Content -Path $hintFile -Raw).Trim() - } - if (-not [string]::IsNullOrWhiteSpace($rel)) { - $candidate = Join-Path $script:BundledInstallRoot $rel - if (Test-Path $candidate) { - return $candidate - } - } - } - - $chromeDir = Join-Path $script:BundledInstallRoot "tools\chrome" - if (Test-Path $chromeDir) { - $found = Get-ChildItem -Path $chromeDir -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match 'chrome-win' } | - Select-Object -First 1 - if ($found) { - return $found.FullName - } - $found = Get-ChildItem -Path $chromeDir -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($found) { - return $found.FullName - } - } - - return $null -} - function Find-SystemBrowserPath { $candidates = @( "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe", @@ -1123,17 +1042,7 @@ function Install-ChromeForTesting { } function Configure-AgentBrowserBrowser { - $browserPath = Find-BundledChromePath - - if (-not [string]::IsNullOrWhiteSpace($browserPath)) { - Write-Info "Using bundled Chrome for Testing. agent-browser will use: $browserPath" - } - else { - $browserPath = Find-SystemBrowserPath - if (-not [string]::IsNullOrWhiteSpace($browserPath)) { - Write-Info "Detected system Chrome/Chromium. agent-browser will use: $browserPath" - } - } + $browserPath = Find-SystemBrowserPath if ([string]::IsNullOrWhiteSpace($browserPath)) { $browserPath = Resolve-ChromeForTestingPath -BrowserDir (Get-ChromeForTestingDir) @@ -1150,6 +1059,9 @@ function Configure-AgentBrowserBrowser { Write-Info "Found existing Chrome for Testing. agent-browser will use: $browserPath" } } + else { + Write-Info "Detected system Chrome/Chromium. agent-browser will use: $browserPath" + } $env:AGENT_BROWSER_EXECUTABLE_PATH = $browserPath [Environment]::SetEnvironmentVariable("AGENT_BROWSER_EXECUTABLE_PATH", $browserPath, "User") @@ -1158,29 +1070,12 @@ function Configure-AgentBrowserBrowser { function Install-AgentBrowser { if (-not (Test-Command "agent-browser")) { Write-Info "Installing the agent-browser CLI..." - if ($script:BundledInstallRoot) { - $npmPrefix = Join-Path $script:BundledInstallRoot "tools\npm-global" - New-Item -ItemType Directory -Path $npmPrefix -Force | Out-Null - $null = Invoke-NativeCommandOrFail ` - -Description "agent-browser CLI installation (bundled prefix)" ` - -FilePath "npm.cmd" ` - -ArgumentList @("install", "--prefix", $npmPrefix, "agent-browser") ` - -Environment @{ npm_config_registry = $script:NpmRegistry } ` - -StreamOutput - $npmBin = Join-Path $npmPrefix "node_modules\.bin" - if (Test-Path $npmBin) { - Add-PathEntry $npmBin - Ensure-UserPathEntry $npmBin - } - } - else { - $null = Invoke-NativeCommandOrFail ` - -Description "agent-browser CLI installation" ` - -FilePath "npm.cmd" ` - -ArgumentList @("install", "--global", "agent-browser") ` - -Environment @{ npm_config_registry = $script:NpmRegistry } ` - -StreamOutput - } + $null = Invoke-NativeCommandOrFail ` + -Description "agent-browser CLI installation" ` + -FilePath "npm.cmd" ` + -ArgumentList @("install", "--global", "agent-browser") ` + -Environment @{ npm_config_registry = $script:NpmRegistry } ` + -StreamOutput Refresh-Path if (-not (Test-Command "agent-browser")) { @@ -1243,35 +1138,14 @@ function Main { Assert-Administrator Refresh-Path - Initialize-BundledToolchainIfPresent if (-not (Resolve-RootDir)) { Show-CloneHintAndExit } Write-Info (Get-LocalizedText -English "Project directory: $RootDir" -Chinese "项目目录: $RootDir") - if (-not $script:BundledInstallRoot) { - Install-Uv - } - else { - Write-Info (Get-LocalizedText -English "Using bundled uv under FLOCKS_INSTALL_ROOT (skip remote uv installer)." -Chinese "使用 FLOCKS_INSTALL_ROOT 下的捆绑 uv(跳过远程 uv 安装脚本)。") - if (-not (Test-Command "uv")) { - Fail "Bundled uv.exe not found on PATH after initialization." - } - } - - if (-not $script:BundledInstallRoot) { - Ensure-NpmInstalled - } - else { - Refresh-Path - if (-not (Test-Command "npm.cmd")) { - Fail "Bundled Node did not expose npm.cmd. Expected under tools\node." - } - if (-not (Test-NodeVersionRequirement)) { - Fail "Bundled Node.js does not meet the minimum major version $MinNodeMajor." - } - } + Install-Uv + Ensure-NpmInstalled Initialize-InstallSources Write-Info (Get-LocalizedText -English "Installing Python backend dependencies (including tests and lint tools) with uv sync --group dev..." -Chinese "正在使用 uv sync --group dev 安装 Python 后端依赖(含测试与 lint 工具)...") From f8f8614b6c8bf8b29301a6bd39dbad94a859faf4 Mon Sep 17 00:00:00 2001 From: chenjie Date: Wed, 15 Apr 2026 13:13:01 +0800 Subject: [PATCH 02/17] debug --- packaging/windows/build-staging.ps1 | 53 ++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index af401d50..ef431a25 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -29,6 +29,46 @@ function Ensure-EmptyDir { New-Item -ItemType Directory -Path $Path -Force | Out-Null } +function Resolve-CacheRoot { + 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-Item -Path $CachePath -Force + } + + Write-Host "[build-staging] Downloading $Label ..." + $tmpPath = "$CachePath.download" + if (Test-Path $tmpPath) { + Remove-Item -Path $tmpPath -Force + } + Invoke-WebRequest -Uri $Url -OutFile $tmpPath -UseBasicParsing + Move-Item -Path $tmpPath -Destination $CachePath -Force +} + Write-Host "[build-staging] RepoRoot: $RepoRoot" Write-Host "[build-staging] OutputDir: $OutputDir" @@ -36,6 +76,7 @@ $manifest = Read-Manifest -Path $ManifestPath $uvVersion = $manifest.uv.version $nodeVersion = $manifest.nodejs.version $nodeSuffix = $manifest.nodejs.windows_zip_suffix +$cacheRoot = Resolve-CacheRoot Ensure-EmptyDir -Path $OutputDir @@ -51,25 +92,21 @@ 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 $env:TEMP "uv-win-$uvVersion.zip" -Write-Host "[build-staging] Downloading uv $uvVersion ..." -Invoke-WebRequest -Uri $uvUrl -OutFile $uvZip -UseBasicParsing +$uvZip = Join-Path $cacheRoot "downloads\uv-$uvVersion-$uvZipName" +Get-OrDownloadFile -Url $uvUrl -CachePath $uvZip -Label "uv $uvVersion" Expand-Archive -Path $uvZip -DestinationPath $toolsUv -Force -Remove-Item $uvZip -Force # Node.js official zip (portable) $nodeZipName = "node-v$nodeVersion-$nodeSuffix.zip" $nodeUrl = "https://nodejs.org/dist/v$nodeVersion/$nodeZipName" -$nodeZip = Join-Path $env:TEMP $nodeZipName -Write-Host "[build-staging] Downloading Node $nodeVersion ..." -Invoke-WebRequest -Uri $nodeUrl -OutFile $nodeZip -UseBasicParsing +$nodeZip = Join-Path $cacheRoot "downloads\$nodeZipName" +Get-OrDownloadFile -Url $nodeUrl -CachePath $nodeZip -Label "Node $nodeVersion" $nodeExtract = Join-Path $env:TEMP "node-extract-$nodeVersion" if (Test-Path $nodeExtract) { Remove-Item $nodeExtract -Recurse -Force } New-Item -ItemType Directory -Path $nodeExtract -Force | Out-Null Expand-Archive -Path $nodeZip -DestinationPath $nodeExtract -Force -Remove-Item $nodeZip -Force $inner = Get-ChildItem -Path $nodeExtract -Directory | Select-Object -First 1 if (-not $inner) { throw "Unexpected Node zip layout" From 802d708325e596229333eb0a37b36c71e88e83bf Mon Sep 17 00:00:00 2001 From: chenjie Date: Wed, 15 Apr 2026 13:15:39 +0800 Subject: [PATCH 03/17] debug --- packaging/windows/build-staging.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index ef431a25..608b594a 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -54,7 +54,7 @@ function Get-OrDownloadFile { if (Test-Path $CachePath) { $existing = Get-Item -Path $CachePath if ($existing.Length -gt 0) { - Write-Host "[build-staging] Reusing cached $Label: $CachePath" + Write-Host "[build-staging] Reusing cached ${Label}: $CachePath" return } Remove-Item -Path $CachePath -Force From 6dc2b86c011c57e52fec73c54545664006de763e Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 17 Apr 2026 13:49:21 +0800 Subject: [PATCH 04/17] fix(windows): keep installer artifacts out of git history Keep the Windows packaging code changes while excluding generated installer output from version control. Update the packaging checks and scripts so release artifacts are built in CI instead of being committed. --- .../workflows/windows-packaging-release.yml | 28 ++-- .github/workflows/windows-packaging.yml | 31 ++-- .gitignore | 3 + packaging/windows/build-installer.ps1 | 40 +++++ packaging/windows/build-staging.ps1 | 144 +++++++++++++----- packaging/windows/flocks-setup.iss | 13 +- packaging/windows/shim/flocks-start.cmd | 19 --- scripts/bootstrap-windows.ps1 | 16 +- scripts/install.ps1 | 75 ++++++++- scripts/install_zh.ps1 | 4 + .../test_browser_runtime_configuration.py | 2 +- 11 files changed, 281 insertions(+), 94 deletions(-) create mode 100644 packaging/windows/build-installer.ps1 delete mode 100644 packaging/windows/shim/flocks-start.cmd diff --git a/.github/workflows/windows-packaging-release.yml b/.github/workflows/windows-packaging-release.yml index 39c1589a..136fe5d5 100644 --- a/.github/workflows/windows-packaging-release.yml +++ b/.github/workflows/windows-packaging-release.yml @@ -1,7 +1,6 @@ -# Build Tier-B staging zip and attach it to the GitHub Release for the pushed tag. -# Long-term download: use Release assets (not Actions artifacts). Push tag v* after creating a Release, or let softprops create the release. +# Build the Windows installer and attach it to the GitHub Release for the pushed tag. -name: Windows packaging — release asset +name: Windows packaging — release installer on: push: @@ -18,21 +17,30 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Build staging zip + - 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" - & "${{ github.workspace }}/packaging/windows/build-staging.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" - $name = "flocks-windows-staging-${{ github.ref_name }}.zip" - $zip = Join-Path $env:RUNNER_TEMP $name - Compress-Archive -Path (Join-Path $out '*') -DestinationPath $zip -Force - Add-Content -Path $env:GITHUB_OUTPUT -Value "zip_path=$zip" -Encoding utf8NoBOM + & "${{ github.workspace }}/packaging/windows/build-installer.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" + $builtInstaller = Join-Path "${{ github.workspace }}" "packaging/windows/Output/FlocksSetup.exe" + if (-not (Test-Path $builtInstaller)) { + throw "Installer not found: $builtInstaller" + } + $name = "FlocksSetup-${{ github.ref_name }}.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.zip_path }} + files: ${{ steps.pack.outputs.installer_path }} generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index 7671f12a..eb79ae18 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -1,4 +1,4 @@ -name: Windows packaging (Tier B staging) +name: Windows packaging (installer) on: workflow_dispatch: @@ -6,35 +6,42 @@ on: paths: - "packaging/windows/**" - "scripts/install.ps1" + - "scripts/install_zh.ps1" - "scripts/bootstrap-windows.ps1" - "flocks/cli/service_manager.py" - ".github/workflows/windows-packaging.yml" + - ".github/workflows/windows-packaging-release.yml" permissions: contents: read jobs: - staging: + installer: runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Build staging directory + - 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" - & "${{ github.workspace }}/packaging/windows/build-staging.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" - $zip = Join-Path $env:RUNNER_TEMP "flocks-windows-staging.zip" - Compress-Archive -Path (Join-Path $out '*') -DestinationPath $zip -Force - "ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append + & "${{ github.workspace }}/packaging/windows/build-installer.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" + $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 staging artifact + - name: Upload installer artifact uses: actions/upload-artifact@v4 with: - name: flocks-windows-staging - path: ${{ runner.temp }}/flocks-windows-staging.zip + name: flocks-windows-installer + path: ${{ runner.temp }}/FlocksSetup.exe if-no-files-found: error - # GitHub caps retention by plan; explicit max avoids surprises. For permanent - # downloads use a GitHub Release (see packaging/windows/DOWNLOAD-HOSTING.txt). retention-days: 90 diff --git a/.gitignore b/.gitignore index 5ac51ccb..62427af7 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,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/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 new file mode 100644 index 00000000..1e51e4cf --- /dev/null +++ b/packaging/windows/build-installer.ps1 @@ -0,0 +1,40 @@ +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 = "" +) + +$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..." +& powershell -NoProfile -ExecutionPolicy Bypass -File $buildStagingScript -OutputDir $OutputDir -RepoRoot $RepoRoot -ManifestPath $ManifestPath -CacheRoot $CacheRoot +if ($LASTEXITCODE -ne 0) { + throw "Staging build failed with exit code $LASTEXITCODE" +} + +Write-Host "[build-installer] Compiling Inno Setup installer..." +& $InnoSetupCompilerPath $installerScript ("/DStagingRoot=" + $OutputDir) +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 index 608b594a..45957d14 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -1,4 +1,4 @@ -# Build Tier-B staging directory: tools/ (uv, node, Chrome for Testing) + flocks/ (repository copy). No .venv — user runs bootstrap later. +# 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: @@ -8,7 +8,8 @@ param( [Parameter(Mandatory = $true)] [string]$OutputDir, [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, - [string]$ManifestPath = (Join-Path $PSScriptRoot "versions.manifest.json") + [string]$ManifestPath = (Join-Path $PSScriptRoot "versions.manifest.json"), + [string]$CacheRoot = "" ) $ErrorActionPreference = "Stop" @@ -23,13 +24,57 @@ function Read-Manifest { function Ensure-EmptyDir { param([string]$Path) - if (Test-Path $Path) { - Remove-Item -Path $Path -Recurse -Force - } + 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" } @@ -57,16 +102,29 @@ function Get-OrDownloadFile { Write-Host "[build-staging] Reusing cached ${Label}: $CachePath" return } - Remove-Item -Path $CachePath -Force + Remove-PathWithRetry -Path $CachePath } Write-Host "[build-staging] Downloading $Label ..." + $maxAttempts = 3 $tmpPath = "$CachePath.download" - if (Test-Path $tmpPath) { - Remove-Item -Path $tmpPath -Force + 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 + } } - Invoke-WebRequest -Uri $Url -OutFile $tmpPath -UseBasicParsing - Move-Item -Path $tmpPath -Destination $CachePath -Force } Write-Host "[build-staging] RepoRoot: $RepoRoot" @@ -76,7 +134,8 @@ $manifest = Read-Manifest -Path $ManifestPath $uvVersion = $manifest.uv.version $nodeVersion = $manifest.nodejs.version $nodeSuffix = $manifest.nodejs.windows_zip_suffix -$cacheRoot = Resolve-CacheRoot +$cacheRoot = Resolve-CacheRoot -RepoRoot $RepoRoot -CacheRootOverride $CacheRoot +Write-Host "[build-staging] CacheRoot: $cacheRoot" Ensure-EmptyDir -Path $OutputDir @@ -102,9 +161,7 @@ $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" -if (Test-Path $nodeExtract) { - Remove-Item $nodeExtract -Recurse -Force -} +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 @@ -112,30 +169,34 @@ if (-not $inner) { throw "Unexpected Node zip layout" } Copy-Item -Path (Join-Path $inner.FullName "*") -Destination $toolsNode -Recurse -Force -Remove-Item $nodeExtract -Recurse -Force - -# Chrome for Testing (bundled browser for agent-browser; avoids relying on end-user npx at first install) -$npxCmd = Join-Path $toolsNode "npx.cmd" -if (-not (Test-Path $npxCmd)) { - throw "npx.cmd not found next to bundled Node: $npxCmd" +Remove-PathWithRetry -Path $nodeExtract + +# Chrome for Testing (bundled browser for agent-browser; prefer cached zip over npm-mediated install) +Write-Host "[build-staging] Installing Chrome for Testing to tools\chrome (prefers cached direct download)..." +$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" } -Write-Host "[build-staging] Installing Chrome for Testing to tools\chrome (uses npm registry for @puppeteer/browsers)..." -$prevPath = $env:Path -$env:Path = "$toolsNode;$prevPath" -$puppeteerEnv = @{} -if (-not [string]::IsNullOrWhiteSpace($env:PUPPETEER_CHROME_DOWNLOAD_BASE_URL)) { - $puppeteerEnv["PUPPETEER_CHROME_DOWNLOAD_BASE_URL"] = $env:PUPPETEER_CHROME_DOWNLOAD_BASE_URL +$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" } -try { - $cfTResult = & $npxCmd "--yes" "@puppeteer/browsers" "install" "chrome@stable" "--path" $toolsChrome 2>&1 - $cfTResult | ForEach-Object { Write-Host $_ } - if ($LASTEXITCODE -ne 0) { - throw "Chrome for Testing install exited with code $LASTEXITCODE" - } -} -finally { - $env:Path = $prevPath +$cftVersion = $stable.version +$cftZip = Join-Path $cacheRoot ("downloads\\chrome-for-testing-win64-stable-" + $cftVersion + ".zip") +Get-OrDownloadFile -Url $stableChrome.url -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' } | @@ -144,7 +205,7 @@ 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 @puppeteer/browsers install" + throw "chrome.exe not found under tools\chrome after extracting bundled Chrome for Testing" } $rootResolved = (Resolve-Path $OutputDir).Path $fullChrome = $chromeExe.FullName @@ -156,18 +217,17 @@ $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) -$exclude = @(".git", ".venv", "node_modules", ".flocks") +# 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 $binDir = Join-Path $OutputDir "bin" New-Item -ItemType Directory -Path $binDir -Force | Out-Null -$shim = Join-Path $PSScriptRoot "shim\flocks-start.cmd" -Copy-Item -Path $shim -Destination (Join-Path $binDir "flocks-start.cmd") -Force -Write-Host "[build-staging] Done. Next: run Inno Setup (flocks-setup.iss) or zip this folder." -Write-Host "[build-staging] On first launch use bin\flocks-start.cmd (runs bootstrap if .venv is missing)." +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 index 29e50ba8..69d2eb4e 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -7,7 +7,7 @@ #endif #define MyAppName "Flocks" -#define MyAppVersion "0.0.0" +#define MyAppVersion "2026.4.16" #define MyAppPublisher "Flocks" [Setup] @@ -15,13 +15,14 @@ AppId={{A8C9E2F1-4B3D-5E6F-9A0B-1C2D3E4F5A6B} AppName={#MyAppName} AppVersion={#MyAppVersion} AppPublisher={#MyAppPublisher} -DefaultDirName={autopf}\{#MyAppName} +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" @@ -35,8 +36,12 @@ Source: "{#StagingRoot}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdir [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 [Icons] -Name: "{autoprograms}\{#MyAppName}\Start Flocks"; Filename: "{app}\bin\flocks-start.cmd"; WorkingDir: "{app}" +Name: "{autoprograms}\{#MyAppName}\Start Flocks"; Filename: "{app}\bin\flocks.cmd"; Parameters: "start"; WorkingDir: "{app}" Name: "{autoprograms}\{#MyAppName}\Flocks repository"; Filename: "{app}\flocks"; WorkingDir: "{app}\flocks" -Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\bin\flocks-start.cmd"; WorkingDir: "{app}"; Tasks: desktopicon +Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\bin\flocks.cmd"; Parameters: "start"; WorkingDir: "{app}"; Tasks: desktopicon + +[Run] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\flocks\scripts\bootstrap-windows.ps1"" -InstallRoot ""{app}"""; StatusMsg: "Setting up Python and JavaScript dependencies..."; Flags: runascurrentuser waituntilterminated diff --git a/packaging/windows/shim/flocks-start.cmd b/packaging/windows/shim/flocks-start.cmd deleted file mode 100644 index 46eb326c..00000000 --- a/packaging/windows/shim/flocks-start.cmd +++ /dev/null @@ -1,19 +0,0 @@ -@echo off -setlocal -rem Install root = parent of bin\ (this file lives in bin\) -pushd "%~dp0.." -set "FLOCKS_INSTALL_ROOT=%CD%" -popd - -if not exist "%FLOCKS_INSTALL_ROOT%\flocks\.venv\Scripts\python.exe" ( - echo [flocks] First run: installing Python and JS dependencies (uv sync, npm^)... - powershell -NoProfile -ExecutionPolicy Bypass -File "%FLOCKS_INSTALL_ROOT%\flocks\scripts\bootstrap-windows.ps1" -InstallRoot "%FLOCKS_INSTALL_ROOT%" - if errorlevel 1 exit /b 1 -) - -if not exist "%FLOCKS_INSTALL_ROOT%\bin\flocks.cmd" ( - echo [flocks] bootstrap did not create bin\flocks.cmd. Check logs. - exit /b 1 -) - -call "%FLOCKS_INSTALL_ROOT%\bin\flocks.cmd" start %* diff --git a/scripts/bootstrap-windows.ps1 b/scripts/bootstrap-windows.ps1 index bbf2faea..dd81233e 100644 --- a/scripts/bootstrap-windows.ps1 +++ b/scripts/bootstrap-windows.ps1 @@ -1,4 +1,4 @@ -# Tier-B / bundled-toolchain bootstrap: run after copying staging (tools\ + flocks\) to the target machine. +# 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\. # # Example (installer post-install or manual): @@ -21,8 +21,20 @@ if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = $InstallRoot.TrimEnd('\', '/') $env:FLOCKS_INSTALL_ROOT = $InstallRoot +$env:FLOCKS_REPO_ROOT = (Join-Path $InstallRoot "flocks") +$env:FLOCKS_SKIP_ADMIN_CHECK = "1" -$installer = Join-Path $InstallRoot "flocks\scripts\install.ps1" +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/" +} + +$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 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3da466a7..fb45f86d 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 } @@ -238,12 +242,27 @@ function Refresh-Path { $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") $env:Path = "$userPath;$machinePath" + $bundledUvBin = $null + $bundledNodeBin = $null + $installRoot = [Environment]::GetEnvironmentVariable("FLOCKS_INSTALL_ROOT") + if (-not [string]::IsNullOrWhiteSpace($installRoot)) { + $bundledUvCandidate = Join-Path $installRoot "tools\uv" + if (Test-Path (Join-Path $bundledUvCandidate "uv.exe")) { + $bundledUvBin = $bundledUvCandidate + } + + $bundledNodeCandidate = Join-Path $installRoot "tools\node" + if (Test-Path (Join-Path $bundledNodeCandidate "npm.cmd")) { + $bundledNodeBin = $bundledNodeCandidate + } + } + $uvBin = Join-Path $HOME ".local\bin" $cargoBin = Join-Path $HOME ".cargo\bin" $bunBin = Join-Path $HOME ".bun\bin" $windowsAppsBin = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps" - foreach ($pathEntry in @($uvBin, $cargoBin, $bunBin, $windowsAppsBin)) { + foreach ($pathEntry in @($bundledUvBin, $bundledNodeBin, $uvBin, $cargoBin, $bunBin, $windowsAppsBin)) { Add-PathEntry $pathEntry } @@ -857,7 +876,13 @@ function Invoke-InstallerCommandWithLockRetry { function Install-FlocksCli { Write-Info "Installing the global flocks CLI..." - $linkDir = Join-Path $HOME ".local\bin" + $installRoot = [Environment]::GetEnvironmentVariable("FLOCKS_INSTALL_ROOT") + if ([string]::IsNullOrWhiteSpace($installRoot)) { + $linkDir = Join-Path $HOME ".local\bin" + } + else { + $linkDir = Join-Path $installRoot "bin" + } if (Test-Command "uv") { $savedEA = $ErrorActionPreference @@ -904,7 +929,12 @@ function Install-FlocksCli { } $wrapperPath = Join-Path $linkDir "flocks.cmd" - $wrapperContent = "@echo off`r`n`"$venvPython`" -m flocks.cli.main %*" + if ([string]::IsNullOrWhiteSpace($installRoot)) { + $wrapperContent = "@echo off`r`n`"$venvPython`" -m flocks.cli.main %*" + } + else { + $wrapperContent = "@echo off`r`nsetlocal`r`nif `"%FLOCKS_INSTALL_ROOT%`"==`"`" pushd `"%~dp0..`" >nul 2>&1`r`nif `"%FLOCKS_INSTALL_ROOT%`"==`"`" set `"FLOCKS_INSTALL_ROOT=%CD%`"`r`nif `"%FLOCKS_INSTALL_ROOT%`"==`"`" popd >nul 2>&1`r`nif `"%FLOCKS_REPO_ROOT%`"==`"`" set `"FLOCKS_REPO_ROOT=%FLOCKS_INSTALL_ROOT%\flocks`"`r`nif `"%FLOCKS_NODE_HOME%`"==`"`" set `"FLOCKS_NODE_HOME=%FLOCKS_INSTALL_ROOT%\tools\node`"`r`nset `"PATH=%FLOCKS_NODE_HOME%;%PATH%`"`r`npushd `"%FLOCKS_REPO_ROOT%`" >nul 2>&1`r`n`"%FLOCKS_INSTALL_ROOT%\flocks\.venv\Scripts\python.exe`" -m flocks.cli.main %*`r`nset `"FLOCKS_EXIT_CODE=%ERRORLEVEL%`"`r`npopd >nul 2>&1`r`nexit /b %FLOCKS_EXIT_CODE%" + } [System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, [System.Text.Encoding]::Default) Ensure-UserPathEntry $linkDir @@ -990,6 +1020,36 @@ function Resolve-ChromeForTestingPath { return $null } +function Resolve-BundledChromePath { + $installRoot = [Environment]::GetEnvironmentVariable("FLOCKS_INSTALL_ROOT") + if ([string]::IsNullOrWhiteSpace($installRoot)) { + return $null + } + + $installRoot = $installRoot.TrimEnd('\', '/') + $bundledChromeDir = Join-Path $installRoot "tools\chrome" + if (-not (Test-Path $bundledChromeDir)) { + return $null + } + + $hintFile = Join-Path $bundledChromeDir "flocks-bundled-chrome.exe.relative.txt" + if (Test-Path $hintFile) { + try { + $relativeExePath = (Get-Content -Path $hintFile -Raw -ErrorAction Stop).Trim() + if (-not [string]::IsNullOrWhiteSpace($relativeExePath)) { + $candidatePath = Join-Path $installRoot $relativeExePath + if (Test-Path $candidatePath) { + return (Resolve-Path $candidatePath).Path + } + } + } + catch { + } + } + + return (Resolve-ChromeForTestingPath -BrowserDir $bundledChromeDir) +} + function Install-ChromeForTesting { $browserDir = Get-ChromeForTestingDir @@ -1013,7 +1073,7 @@ function Install-ChromeForTesting { try { $process = Start-Process ` -FilePath $npxPath ` - -ArgumentList @("--yes", "@puppeteer/browsers", "install", "chrome@stable", "--path", $browserDir) ` + -ArgumentList @("-y", "@puppeteer/browsers", "install", "chrome@stable", "--path", $browserDir) ` -WorkingDirectory $browserDir ` -NoNewWindow ` -Wait ` @@ -1044,6 +1104,13 @@ function Install-ChromeForTesting { function Configure-AgentBrowserBrowser { $browserPath = Find-SystemBrowserPath + if ([string]::IsNullOrWhiteSpace($browserPath)) { + $browserPath = Resolve-BundledChromePath + if (-not [string]::IsNullOrWhiteSpace($browserPath)) { + Write-Info "Found bundled Chrome for Testing. agent-browser will use: $browserPath" + } + } + if ([string]::IsNullOrWhiteSpace($browserPath)) { $browserPath = Resolve-ChromeForTestingPath -BrowserDir (Get-ChromeForTestingDir) if ([string]::IsNullOrWhiteSpace($browserPath)) { 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/scripts/test_browser_runtime_configuration.py b/tests/scripts/test_browser_runtime_configuration.py index 2369281b..ec4b3c5d 100644 --- a/tests/scripts/test_browser_runtime_configuration.py +++ b/tests/scripts/test_browser_runtime_configuration.py @@ -29,7 +29,7 @@ def test_bash_installer_prefers_explicit_browser_configuration() -> None: def test_powershell_installer_prefers_explicit_browser_configuration() -> None: script = (SCRIPT_DIR / "install.ps1").read_text(encoding="utf-8-sig") - assert "Find-BundledChromePath" in script + assert "Resolve-BundledChromePath" in script assert "flocks-bundled-chrome.exe.relative.txt" in script assert "Find-SystemBrowserPath" in script assert "AGENT_BROWSER_EXECUTABLE_PATH" in script From c4647cf8502c5642b3c70dab04cbf8e4560feabb Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 17 Apr 2026 14:53:19 +0800 Subject: [PATCH 05/17] packing wip --- .../workflows/windows-packaging-release.yml | 10 +- .github/workflows/windows-packaging.yml | 1 - flocks/cli/service_manager.py | 16 ++ packaging/windows/bootstrap-windows.ps1 | 141 ++++++++++++++++++ packaging/windows/build-installer.ps1 | 9 +- packaging/windows/build-staging.ps1 | 39 +++-- packaging/windows/flocks-setup.iss | 22 ++- packaging/windows/versions.manifest.json | 1 + scripts/bootstrap-windows.ps1 | 49 ------ scripts/install.ps1 | 71 +-------- tests/cli/test_service_manager.py | 57 +++++++ .../test_browser_runtime_configuration.py | 82 +++++++++- 12 files changed, 358 insertions(+), 140 deletions(-) create mode 100644 packaging/windows/bootstrap-windows.ps1 delete mode 100644 scripts/bootstrap-windows.ps1 diff --git a/.github/workflows/windows-packaging-release.yml b/.github/workflows/windows-packaging-release.yml index 136fe5d5..71c625ad 100644 --- a/.github/workflows/windows-packaging-release.yml +++ b/.github/workflows/windows-packaging-release.yml @@ -27,12 +27,17 @@ jobs: shell: pwsh run: | $out = Join-Path $env:RUNNER_TEMP "flocks-staging" - & "${{ github.workspace }}/packaging/windows/build-installer.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" + $tag = "${{ github.ref_name }}" + $appVersion = $tag.TrimStart('v') + & "${{ github.workspace }}/packaging/windows/build-installer.ps1" ` + -OutputDir $out ` + -RepoRoot "${{ github.workspace }}" ` + -AppVersion $appVersion $builtInstaller = Join-Path "${{ github.workspace }}" "packaging/windows/Output/FlocksSetup.exe" if (-not (Test-Path $builtInstaller)) { throw "Installer not found: $builtInstaller" } - $name = "FlocksSetup-${{ github.ref_name }}.exe" + $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 @@ -42,5 +47,6 @@ jobs: 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 index eb79ae18..42cf0ff0 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -7,7 +7,6 @@ on: - "packaging/windows/**" - "scripts/install.ps1" - "scripts/install_zh.ps1" - - "scripts/bootstrap-windows.ps1" - "flocks/cli/service_manager.py" - ".github/workflows/windows-packaging.yml" - ".github/workflows/windows-packaging-release.yml" diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 56e068a4..0d574990 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -1372,6 +1372,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/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 index 1e51e4cf..d2790556 100644 --- a/packaging/windows/build-installer.ps1 +++ b/packaging/windows/build-installer.ps1 @@ -3,7 +3,8 @@ [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]$CacheRoot = "", + [string]$AppVersion = "" ) $ErrorActionPreference = "Stop" @@ -32,7 +33,11 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "[build-installer] Compiling Inno Setup installer..." -& $InnoSetupCompilerPath $installerScript ("/DStagingRoot=" + $OutputDir) +$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" } diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index 45957d14..16fd235c 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -172,20 +172,31 @@ Copy-Item -Path (Join-Path $inner.FullName "*") -Destination $toolsNode -Recurse 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)..." -$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" +$pinnedCftVersion = $manifest.chrome_for_testing.version +if (-not [string]::IsNullOrWhiteSpace($pinnedCftVersion)) { + Write-Host "[build-staging] Using pinned Chrome for Testing version: $pinnedCftVersion" + $cftVersion = $pinnedCftVersion + $cftUrl = "https://storage.googleapis.com/chrome-for-testing-public/$cftVersion/win64/chrome-win64.zip" } -$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" +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 + $cftUrl = $stableChrome.url } -$cftVersion = $stable.version -$cftZip = Join-Path $cacheRoot ("downloads\\chrome-for-testing-win64-stable-" + $cftVersion + ".zip") -Get-OrDownloadFile -Url $stableChrome.url -CachePath $cftZip -Label ("Chrome for Testing " + $cftVersion) +$cftZip = Join-Path $cacheRoot ("downloads\\chrome-for-testing-win64-" + $cftVersion + ".zip") +Get-OrDownloadFile -Url $cftUrl -CachePath $cftZip -Label ("Chrome for Testing " + $cftVersion) $cftExtract = Join-Path $env:TEMP ("cft-extract-" + $cftVersion) Remove-PathWithRetry -Path $cftExtract @@ -227,7 +238,9 @@ if ($LASTEXITCODE -ge 8) { # robocopy uses 0-7 as success states; normalize process exit code for callers. $global:LASTEXITCODE = 0 -$binDir = Join-Path $OutputDir "bin" -New-Item -ItemType Directory -Path $binDir -Force | Out-Null +# 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 index 69d2eb4e..9b5040c8 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -6,8 +6,12 @@ #define StagingRoot "dist\staging" #endif +#ifndef AppVersion + #define AppVersion "0.0.0-dev" +#endif + #define MyAppName "Flocks" -#define MyAppVersion "2026.4.16" +#define MyAppVersion AppVersion #define MyAppPublisher "Flocks" [Setup] @@ -27,6 +31,13 @@ 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%nPlease open a NEW terminal window before running `flocks start`, so the updated environment variables (PATH, FLOCKS_NODE_HOME, ...) take effect.%n%n请重新打开终端窗口后再执行 `flocks start`,以便新的环境变量(PATH、FLOCKS_NODE_HOME 等)生效。 + [Tasks] Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional icons:" @@ -38,10 +49,13 @@ Root: HKCU; Subkey: "Environment"; ValueType: string; ValueName: "FLOCKS_INSTALL 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: "{app}\bin\flocks.cmd"; Parameters: "start"; WorkingDir: "{app}" +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: "{app}\bin\flocks.cmd"; Parameters: "start"; WorkingDir: "{app}"; Tasks: desktopicon +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\scripts\bootstrap-windows.ps1"" -InstallRoot ""{app}"""; StatusMsg: "Setting up Python and JavaScript dependencies..."; Flags: runascurrentuser waituntilterminated +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 diff --git a/packaging/windows/versions.manifest.json b/packaging/windows/versions.manifest.json index 2367f237..cd03b89e 100644 --- a/packaging/windows/versions.manifest.json +++ b/packaging/windows/versions.manifest.json @@ -8,6 +8,7 @@ "windows_zip_suffix": "win-x64" }, "chrome_for_testing": { + "version": "147.0.7727.57", "channel": "stable" } } diff --git a/scripts/bootstrap-windows.ps1 b/scripts/bootstrap-windows.ps1 deleted file mode 100644 index dd81233e..00000000 --- a/scripts/bootstrap-windows.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -# 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\. -# -# Example (installer post-install or manual): -# $env:FLOCKS_INSTALL_ROOT = "D:\Flocks" -# powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\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_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/" -} - -$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/scripts/install.ps1 b/scripts/install.ps1 index fb45f86d..45acafc1 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -242,27 +242,12 @@ function Refresh-Path { $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") $env:Path = "$userPath;$machinePath" - $bundledUvBin = $null - $bundledNodeBin = $null - $installRoot = [Environment]::GetEnvironmentVariable("FLOCKS_INSTALL_ROOT") - if (-not [string]::IsNullOrWhiteSpace($installRoot)) { - $bundledUvCandidate = Join-Path $installRoot "tools\uv" - if (Test-Path (Join-Path $bundledUvCandidate "uv.exe")) { - $bundledUvBin = $bundledUvCandidate - } - - $bundledNodeCandidate = Join-Path $installRoot "tools\node" - if (Test-Path (Join-Path $bundledNodeCandidate "npm.cmd")) { - $bundledNodeBin = $bundledNodeCandidate - } - } - $uvBin = Join-Path $HOME ".local\bin" $cargoBin = Join-Path $HOME ".cargo\bin" $bunBin = Join-Path $HOME ".bun\bin" $windowsAppsBin = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps" - foreach ($pathEntry in @($bundledUvBin, $bundledNodeBin, $uvBin, $cargoBin, $bunBin, $windowsAppsBin)) { + foreach ($pathEntry in @($uvBin, $cargoBin, $bunBin, $windowsAppsBin)) { Add-PathEntry $pathEntry } @@ -876,13 +861,7 @@ function Invoke-InstallerCommandWithLockRetry { function Install-FlocksCli { Write-Info "Installing the global flocks CLI..." - $installRoot = [Environment]::GetEnvironmentVariable("FLOCKS_INSTALL_ROOT") - if ([string]::IsNullOrWhiteSpace($installRoot)) { - $linkDir = Join-Path $HOME ".local\bin" - } - else { - $linkDir = Join-Path $installRoot "bin" - } + $linkDir = Join-Path $HOME ".local\bin" if (Test-Command "uv") { $savedEA = $ErrorActionPreference @@ -929,12 +908,7 @@ function Install-FlocksCli { } $wrapperPath = Join-Path $linkDir "flocks.cmd" - if ([string]::IsNullOrWhiteSpace($installRoot)) { - $wrapperContent = "@echo off`r`n`"$venvPython`" -m flocks.cli.main %*" - } - else { - $wrapperContent = "@echo off`r`nsetlocal`r`nif `"%FLOCKS_INSTALL_ROOT%`"==`"`" pushd `"%~dp0..`" >nul 2>&1`r`nif `"%FLOCKS_INSTALL_ROOT%`"==`"`" set `"FLOCKS_INSTALL_ROOT=%CD%`"`r`nif `"%FLOCKS_INSTALL_ROOT%`"==`"`" popd >nul 2>&1`r`nif `"%FLOCKS_REPO_ROOT%`"==`"`" set `"FLOCKS_REPO_ROOT=%FLOCKS_INSTALL_ROOT%\flocks`"`r`nif `"%FLOCKS_NODE_HOME%`"==`"`" set `"FLOCKS_NODE_HOME=%FLOCKS_INSTALL_ROOT%\tools\node`"`r`nset `"PATH=%FLOCKS_NODE_HOME%;%PATH%`"`r`npushd `"%FLOCKS_REPO_ROOT%`" >nul 2>&1`r`n`"%FLOCKS_INSTALL_ROOT%\flocks\.venv\Scripts\python.exe`" -m flocks.cli.main %*`r`nset `"FLOCKS_EXIT_CODE=%ERRORLEVEL%`"`r`npopd >nul 2>&1`r`nexit /b %FLOCKS_EXIT_CODE%" - } + $wrapperContent = "@echo off`r`n`"$venvPython`" -m flocks.cli.main %*" [System.IO.File]::WriteAllText($wrapperPath, $wrapperContent, [System.Text.Encoding]::Default) Ensure-UserPathEntry $linkDir @@ -1020,36 +994,6 @@ function Resolve-ChromeForTestingPath { return $null } -function Resolve-BundledChromePath { - $installRoot = [Environment]::GetEnvironmentVariable("FLOCKS_INSTALL_ROOT") - if ([string]::IsNullOrWhiteSpace($installRoot)) { - return $null - } - - $installRoot = $installRoot.TrimEnd('\', '/') - $bundledChromeDir = Join-Path $installRoot "tools\chrome" - if (-not (Test-Path $bundledChromeDir)) { - return $null - } - - $hintFile = Join-Path $bundledChromeDir "flocks-bundled-chrome.exe.relative.txt" - if (Test-Path $hintFile) { - try { - $relativeExePath = (Get-Content -Path $hintFile -Raw -ErrorAction Stop).Trim() - if (-not [string]::IsNullOrWhiteSpace($relativeExePath)) { - $candidatePath = Join-Path $installRoot $relativeExePath - if (Test-Path $candidatePath) { - return (Resolve-Path $candidatePath).Path - } - } - } - catch { - } - } - - return (Resolve-ChromeForTestingPath -BrowserDir $bundledChromeDir) -} - function Install-ChromeForTesting { $browserDir = Get-ChromeForTestingDir @@ -1073,7 +1017,7 @@ function Install-ChromeForTesting { try { $process = Start-Process ` -FilePath $npxPath ` - -ArgumentList @("-y", "@puppeteer/browsers", "install", "chrome@stable", "--path", $browserDir) ` + -ArgumentList @("--yes", "@puppeteer/browsers", "install", "chrome@stable", "--path", $browserDir) ` -WorkingDirectory $browserDir ` -NoNewWindow ` -Wait ` @@ -1104,13 +1048,6 @@ function Install-ChromeForTesting { function Configure-AgentBrowserBrowser { $browserPath = Find-SystemBrowserPath - if ([string]::IsNullOrWhiteSpace($browserPath)) { - $browserPath = Resolve-BundledChromePath - if (-not [string]::IsNullOrWhiteSpace($browserPath)) { - Write-Info "Found bundled Chrome for Testing. agent-browser will use: $browserPath" - } - } - if ([string]::IsNullOrWhiteSpace($browserPath)) { $browserPath = Resolve-ChromeForTestingPath -BrowserDir (Get-ChromeForTestingDir) if ([string]::IsNullOrWhiteSpace($browserPath)) { diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index 37df23ff..d8d6c1af 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -65,6 +65,63 @@ def test_resolve_node_executable_prefers_flocks_install_root_tools_node( 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_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") diff --git a/tests/scripts/test_browser_runtime_configuration.py b/tests/scripts/test_browser_runtime_configuration.py index ec4b3c5d..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: @@ -29,8 +30,6 @@ def test_bash_installer_prefers_explicit_browser_configuration() -> None: def test_powershell_installer_prefers_explicit_browser_configuration() -> None: script = (SCRIPT_DIR / "install.ps1").read_text(encoding="utf-8-sig") - assert "Resolve-BundledChromePath" in script - assert "flocks-bundled-chrome.exe.relative.txt" in script assert "Find-SystemBrowserPath" in script assert "AGENT_BROWSER_EXECUTABLE_PATH" in script assert "Get-ChromeForTestingDir" in script @@ -51,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 From 7df7d3efea2b2fc704513f3878fd3a5f90f83694 Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 17 Apr 2026 21:21:51 +0800 Subject: [PATCH 06/17] fix(windows): harden uninstall cleanup and script compatibility Make Windows uninstall cleanup safer by avoiding recursive install-root deletion and only clearing browser env vars that point to the current install. Also normalize packaging PowerShell scripts to UTF-8 BOM with CRLF for Windows PowerShell 5.1 compatibility. --- packaging/README.md | 81 ++++ packaging/windows/build-installer.ps1 | 15 +- packaging/windows/build-staging.ps1 | 17 +- packaging/windows/flocks-setup.iss | 14 + .../windows/uninstall-flocks-user-state.ps1 | 372 ++++++++++++++++++ 5 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 packaging/README.md create mode 100644 packaging/windows/uninstall-flocks-user-state.ps1 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/build-installer.ps1 b/packaging/windows/build-installer.ps1 index d2790556..1f1a56b0 100644 --- a/packaging/windows/build-installer.ps1 +++ b/packaging/windows/build-installer.ps1 @@ -27,7 +27,20 @@ if (-not (Test-Path $installerScript)) { } Write-Host "[build-installer] Building staging directory..." -& powershell -NoProfile -ExecutionPolicy Bypass -File $buildStagingScript -OutputDir $OutputDir -RepoRoot $RepoRoot -ManifestPath $ManifestPath -CacheRoot $CacheRoot +# 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" } diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index 16fd235c..79708f29 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -1,4 +1,4 @@ -# Build Tier-B staging directory: tools/ (uv, node, Chrome for Testing) + flocks/ (repository copy). No .venv — installer/bootstrap runs later. +# 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: @@ -195,7 +195,20 @@ else { $cftVersion = $stable.version $cftUrl = $stableChrome.url } -$cftZip = Join-Path $cacheRoot ("downloads\\chrome-for-testing-win64-" + $cftVersion + ".zip") +# 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-OrDownloadFile -Url $cftUrl -CachePath $cftZip -Label ("Chrome for Testing " + $cftVersion) $cftExtract = Join-Path $env:TEMP ("cft-extract-" + $cftVersion) diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 9b5040c8..92d0c0b8 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -59,3 +59,17 @@ Name: "{userdesktop}\{#MyAppName}"; Filename: "{%USERPROFILE}\.local\bin\flocks. [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}"""; 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}" +; Do not recursively delete {app}\* during uninstall. Users may choose a custom +; existing directory and broad sweep can remove unrelated files. +Type: dirifempty; Name: "{app}" 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 From a3f8570701b46b01a45185085612f1a939a67e4e Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 17 Apr 2026 22:04:02 +0800 Subject: [PATCH 07/17] fix(windows): cache packaging downloads and add Chrome fallback sources Cache Windows packaging downloads in CI and support configurable Chrome mirror URL fallback before the default source to reduce installer build latency. --- .../workflows/windows-packaging-release.yml | 8 ++++ .github/workflows/windows-packaging.yml | 8 ++++ packaging/windows/build-staging.ps1 | 48 +++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-packaging-release.yml b/.github/workflows/windows-packaging-release.yml index 71c625ad..a55ca4b3 100644 --- a/.github/workflows/windows-packaging-release.yml +++ b/.github/workflows/windows-packaging-release.yml @@ -17,6 +17,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Cache bundled tool downloads + uses: actions/cache@v4 + 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: | diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index 42cf0ff0..d0188c0d 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -21,6 +21,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Cache bundled tool downloads + uses: actions/cache@v4 + 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: | diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index 79708f29..175bb635 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -127,6 +127,36 @@ function Get-OrDownloadFile { } } +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" @@ -175,10 +205,17 @@ Remove-PathWithRetry -Path $nodeExtract # 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 - $cftUrl = "https://storage.googleapis.com/chrome-for-testing-public/$cftVersion/win64/chrome-win64.zip" + 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..." @@ -193,7 +230,12 @@ else { throw "Failed to resolve win64 download URL from Chrome for Testing metadata" } $cftVersion = $stable.version - $cftUrl = $stableChrome.url + 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" @@ -209,7 +251,7 @@ elseif (Test-Path -LiteralPath $cftZipAltStable -PathType Leaf) { else { $cftZip = $cftZipPrimary } -Get-OrDownloadFile -Url $cftUrl -CachePath $cftZip -Label ("Chrome for Testing " + $cftVersion) +Get-OrDownloadFileFromCandidates -Urls $cftUrls -CachePath $cftZip -Label ("Chrome for Testing " + $cftVersion) $cftExtract = Join-Path $env:TEMP ("cft-extract-" + $cftVersion) Remove-PathWithRetry -Path $cftExtract From 7dbfe38aa0568e27f0fdda05cdc9cf8bb9491e28 Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 14:45:03 +0800 Subject: [PATCH 08/17] fix(windows): harden packaged startup and runtime pinning Ensure Windows packaging runs JavaScript actions on Node 24, verifies bundled uv/node/chrome versions against the manifest, and makes Finish-page launch robust by injecting install env vars without single-quote path hazards. --- .../workflows/windows-packaging-release.yml | 37 +++++++++++++++++++ .github/workflows/windows-packaging.yml | 37 +++++++++++++++++++ flocks/cli/service_manager.py | 33 ++++++++++++++++- packaging/windows/flocks-setup.iss | 1 + 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-packaging-release.yml b/.github/workflows/windows-packaging-release.yml index a55ca4b3..93386426 100644 --- a/.github/workflows/windows-packaging-release.yml +++ b/.github/workflows/windows-packaging-release.yml @@ -10,6 +10,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: release-asset: runs-on: windows-latest @@ -35,12 +38,46 @@ jobs: 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 = (& $chromeExe.FullName --version).Trim() + 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" diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index d0188c0d..930edacb 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -14,6 +14,9 @@ on: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: installer: runs-on: windows-latest @@ -38,7 +41,41 @@ jobs: 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 = (& $chromeExe.FullName --version).Trim() + 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" diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 44350c3d..f79b17ec 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -245,9 +245,40 @@ 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 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 diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 92d0c0b8..721f329d 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -59,6 +59,7 @@ Name: "{userdesktop}\{#MyAppName}"; Filename: "{%USERPROFILE}\.local\bin\flocks. [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 +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$env:FLOCKS_INSTALL_ROOT = """"{app}""""; $env:FLOCKS_REPO_ROOT = Join-Path $env:FLOCKS_INSTALL_ROOT """"flocks""""; $env:FLOCKS_NODE_HOME = Join-Path $env:FLOCKS_INSTALL_ROOT """"tools\node""""; $env:Path = $env:FLOCKS_NODE_HOME + """";"""" + $env:Path; $flocksCmd = Join-Path $env:USERPROFILE "".local\bin\flocks.cmd""; if (Test-Path -LiteralPath $flocksCmd) { & $flocksCmd start }"""; WorkingDir: "{%USERPROFILE}"; Description: "Launch Flocks now"; Flags: postinstall nowait skipifsilent runascurrentuser ; Runs before [Files] are deleted: flocks stop (graceful), then taskkill fallback, PATH/env/flocks.cmd cleanup, bundled Chrome junction. [UninstallRun] From f6b167085e9fc1b9da76c85c66afbb18126fedec Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 14:48:15 +0800 Subject: [PATCH 09/17] fix(windows): escape postinstall script braces in installer Prevent Inno Setup from parsing PowerShell script-block braces as constants by escaping the postinstall launch command braces in the Windows installer script. --- packaging/windows/flocks-setup.iss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 721f329d..74e4c493 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -59,7 +59,7 @@ Name: "{userdesktop}\{#MyAppName}"; Filename: "{%USERPROFILE}\.local\bin\flocks. [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 -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$env:FLOCKS_INSTALL_ROOT = """"{app}""""; $env:FLOCKS_REPO_ROOT = Join-Path $env:FLOCKS_INSTALL_ROOT """"flocks""""; $env:FLOCKS_NODE_HOME = Join-Path $env:FLOCKS_INSTALL_ROOT """"tools\node""""; $env:Path = $env:FLOCKS_NODE_HOME + """";"""" + $env:Path; $flocksCmd = Join-Path $env:USERPROFILE "".local\bin\flocks.cmd""; if (Test-Path -LiteralPath $flocksCmd) { & $flocksCmd start }"""; WorkingDir: "{%USERPROFILE}"; Description: "Launch Flocks now"; Flags: postinstall nowait skipifsilent runascurrentuser +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$env:FLOCKS_INSTALL_ROOT = """"{app}""""; $env:FLOCKS_REPO_ROOT = Join-Path $env:FLOCKS_INSTALL_ROOT """"flocks""""; $env:FLOCKS_NODE_HOME = Join-Path $env:FLOCKS_INSTALL_ROOT """"tools\node""""; $env:Path = $env:FLOCKS_NODE_HOME + """";"""" + $env:Path; $flocksCmd = Join-Path $env:USERPROFILE "".local\bin\flocks.cmd""; if (Test-Path -LiteralPath $flocksCmd) {{ & $flocksCmd start }}"""; WorkingDir: "{%USERPROFILE}"; Description: "Launch Flocks now"; Flags: postinstall nowait skipifsilent runascurrentuser ; Runs before [Files] are deleted: flocks stop (graceful), then taskkill fallback, PATH/env/flocks.cmd cleanup, bundled Chrome junction. [UninstallRun] From 127ab2ef7e052ee88e62cefe2f8cb1d10ab1cda1 Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 14:55:18 +0800 Subject: [PATCH 10/17] fix(windows): avoid chrome sandbox probe and add uninstall run id Read bundled Chrome version from file metadata instead of executing chrome.exe in CI, and add RunOnceId for the uninstall cleanup entry to remove installer warnings. --- .github/workflows/windows-packaging-release.yml | 8 +++++++- .github/workflows/windows-packaging.yml | 8 +++++++- packaging/windows/flocks-setup.iss | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-packaging-release.yml b/.github/workflows/windows-packaging-release.yml index 93386426..c0a1a2c1 100644 --- a/.github/workflows/windows-packaging-release.yml +++ b/.github/workflows/windows-packaging-release.yml @@ -62,7 +62,13 @@ jobs: } $uvVersion = (& $uvExe --version).Trim() $nodeVersion = (& $nodeExe --version).Trim() - $chromeVersion = (& $chromeExe.FullName --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)" diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index 930edacb..9349adef 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -60,7 +60,13 @@ jobs: } $uvVersion = (& $uvExe --version).Trim() $nodeVersion = (& $nodeExe --version).Trim() - $chromeVersion = (& $chromeExe.FullName --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)" diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 74e4c493..64cf4882 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -63,7 +63,7 @@ Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Com ; 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}"""; Flags: runascurrentuser +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] From 27830f0549771daf0776f23fd566920be634d1fb Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 15:22:21 +0800 Subject: [PATCH 11/17] chore(ci): upgrade core GitHub actions to Node 24-ready versions Bump checkout/cache/upload-artifact action versions across CI and Windows packaging workflows to remove Node 20 deprecation warnings while keeping workflow behavior unchanged. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docker-publish.yml | 2 +- .github/workflows/windows-packaging-release.yml | 4 ++-- .github/workflows/windows-packaging.yml | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) 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 index c0a1a2c1..2344968a 100644 --- a/.github/workflows/windows-packaging-release.yml +++ b/.github/workflows/windows-packaging-release.yml @@ -18,10 +18,10 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache bundled tool downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: C:\Users\runneradmin\AppData\Local\flocks\cache key: windows-flocks-cache-${{ hashFiles('packaging/windows/versions.manifest.json') }} diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index 9349adef..f77afb48 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -22,10 +22,10 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache bundled tool downloads - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: C:\Users\runneradmin\AppData\Local\flocks\cache key: windows-flocks-cache-${{ hashFiles('packaging/windows/versions.manifest.json') }} @@ -89,7 +89,7 @@ jobs: Copy-Item -Path $exe -Destination (Join-Path $env:RUNNER_TEMP "FlocksSetup.exe") -Force - name: Upload installer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: flocks-windows-installer path: ${{ runner.temp }}/FlocksSetup.exe From 97602319db9cbb038de001930674448ebedbabac Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 15:34:59 +0800 Subject: [PATCH 12/17] fix(windows): make finish launch start flocks reliably Use Start-Process with bundled venv Python as primary path and flocks.cmd as fallback so installer Finish launch no longer exits without starting services. --- packaging/windows/flocks-setup.iss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 64cf4882..e7803c68 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -59,7 +59,7 @@ Name: "{userdesktop}\{#MyAppName}"; Filename: "{%USERPROFILE}\.local\bin\flocks. [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 -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$env:FLOCKS_INSTALL_ROOT = """"{app}""""; $env:FLOCKS_REPO_ROOT = Join-Path $env:FLOCKS_INSTALL_ROOT """"flocks""""; $env:FLOCKS_NODE_HOME = Join-Path $env:FLOCKS_INSTALL_ROOT """"tools\node""""; $env:Path = $env:FLOCKS_NODE_HOME + """";"""" + $env:Path; $flocksCmd = Join-Path $env:USERPROFILE "".local\bin\flocks.cmd""; if (Test-Path -LiteralPath $flocksCmd) {{ & $flocksCmd start }}"""; WorkingDir: "{%USERPROFILE}"; Description: "Launch Flocks now"; Flags: postinstall nowait skipifsilent runascurrentuser +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$env:FLOCKS_INSTALL_ROOT = """"{app}""""; $env:FLOCKS_REPO_ROOT = Join-Path $env:FLOCKS_INSTALL_ROOT """"flocks""""; $env:FLOCKS_NODE_HOME = Join-Path $env:FLOCKS_INSTALL_ROOT """"tools\node""""; $env:Path = $env:FLOCKS_NODE_HOME + """";"""" + $env:Path; $pythonExe = Join-Path $env:FLOCKS_REPO_ROOT "".venv\Scripts\python.exe""; if (Test-Path -LiteralPath $pythonExe) {{ Start-Process -FilePath $pythonExe -ArgumentList ""-m"", ""flocks.cli.main"", ""start"" -WorkingDirectory $env:USERPROFILE -WindowStyle Hidden }} else {{ $flocksCmd = Join-Path $env:USERPROFILE "".local\bin\flocks.cmd""; if (Test-Path -LiteralPath $flocksCmd) {{ Start-Process -FilePath $flocksCmd -ArgumentList ""start"" -WorkingDirectory $env:USERPROFILE -WindowStyle Hidden }} }}"""; WorkingDir: "{%USERPROFILE}"; Description: "Launch Flocks now"; Flags: postinstall nowait skipifsilent runascurrentuser ; Runs before [Files] are deleted: flocks stop (graceful), then taskkill fallback, PATH/env/flocks.cmd cleanup, bundled Chrome junction. [UninstallRun] From 193604790718576a1a47e516444eea3dac765aeb Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 15:52:46 +0800 Subject: [PATCH 13/17] fix(windows): remove finish autostart and clean install directory on uninstall Stop launching Flocks automatically on installer finish, update the completion hint with manual startup options, and recursively delete installed code under {app} during uninstall. --- packaging/windows/flocks-setup.iss | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index e7803c68..05cc12d3 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -36,7 +36,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" ; 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%nPlease open a NEW terminal window before running `flocks start`, so the updated environment variables (PATH, FLOCKS_NODE_HOME, ...) take effect.%n%n请重新打开终端窗口后再执行 `flocks start`,以便新的环境变量(PATH、FLOCKS_NODE_HOME 等)生效。 +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:" @@ -59,7 +59,6 @@ Name: "{userdesktop}\{#MyAppName}"; Filename: "{%USERPROFILE}\.local\bin\flocks. [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 -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$env:FLOCKS_INSTALL_ROOT = """"{app}""""; $env:FLOCKS_REPO_ROOT = Join-Path $env:FLOCKS_INSTALL_ROOT """"flocks""""; $env:FLOCKS_NODE_HOME = Join-Path $env:FLOCKS_INSTALL_ROOT """"tools\node""""; $env:Path = $env:FLOCKS_NODE_HOME + """";"""" + $env:Path; $pythonExe = Join-Path $env:FLOCKS_REPO_ROOT "".venv\Scripts\python.exe""; if (Test-Path -LiteralPath $pythonExe) {{ Start-Process -FilePath $pythonExe -ArgumentList ""-m"", ""flocks.cli.main"", ""start"" -WorkingDirectory $env:USERPROFILE -WindowStyle Hidden }} else {{ $flocksCmd = Join-Path $env:USERPROFILE "".local\bin\flocks.cmd""; if (Test-Path -LiteralPath $flocksCmd) {{ Start-Process -FilePath $flocksCmd -ArgumentList ""start"" -WorkingDirectory $env:USERPROFILE -WindowStyle Hidden }} }}"""; WorkingDir: "{%USERPROFILE}"; Description: "Launch Flocks now"; Flags: postinstall nowait skipifsilent runascurrentuser ; Runs before [Files] are deleted: flocks stop (graceful), then taskkill fallback, PATH/env/flocks.cmd cleanup, bundled Chrome junction. [UninstallRun] @@ -71,6 +70,6 @@ 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}" -; Do not recursively delete {app}\* during uninstall. Users may choose a custom -; existing directory and broad sweep can remove unrelated files. +; Remove all installed code under {app}, then remove the install root directory. +Type: filesandordirs; Name: "{app}\*" Type: dirifempty; Name: "{app}" From e635d559d13bc8700190f9b4bf47ed51dd9db7f2 Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 19:25:48 +0800 Subject: [PATCH 14/17] fix(windows): warn when installer target is Program Files Show a bilingual warning when users choose Program Files as the install directory, clarifying that running or updating Flocks may require admin privileges. Made-with: Cursor --- packaging/windows/flocks-setup.iss | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 05cc12d3..994ec950 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -73,3 +73,57 @@ 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; From d0e72dc6792ecd36bc679762124b35dfe5f20c2e Mon Sep 17 00:00:00 2001 From: chenjie Date: Sat, 18 Apr 2026 19:32:57 +0800 Subject: [PATCH 15/17] fix(windows): restore Chinese finish hint wording for CI Use the Chinese phrase expected by tests in the installer finish label while keeping the reopen-terminal guidance unchanged. Made-with: Cursor --- packaging/windows/flocks-setup.iss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/windows/flocks-setup.iss b/packaging/windows/flocks-setup.iss index 994ec950..bccc5977 100644 --- a/packaging/windows/flocks-setup.iss +++ b/packaging/windows/flocks-setup.iss @@ -36,7 +36,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" ; 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 等)生效。 +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:" From e8549fd4afa627ff87f314e62d9966a5cd42d511 Mon Sep 17 00:00:00 2001 From: chenjie Date: Mon, 20 Apr 2026 19:55:09 +0800 Subject: [PATCH 16/17] fix(windows): upgrade bundled uv for installer compatibility Bundle a newer uv release in the Windows installer so existing user uv.toml settings like python-downloads-json-url no longer break uv sync during setup. Made-with: Cursor --- packaging/windows/versions.manifest.json | 2 +- tests/packaging/test_windows_manifest.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 tests/packaging/test_windows_manifest.py diff --git a/packaging/windows/versions.manifest.json b/packaging/windows/versions.manifest.json index cd03b89e..1d420759 100644 --- a/packaging/windows/versions.manifest.json +++ b/packaging/windows/versions.manifest.json @@ -1,7 +1,7 @@ { "description": "Pinned versions for Windows bundled staging (CI downloads + local packaging). Bump when upgrading toolchains.", "uv": { - "version": "0.6.14" + "version": "0.9.15" }, "nodejs": { "version": "24.14.0", 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) From 4e09b9d9b95e509057e3d8b4a33b2621692edf31 Mon Sep 17 00:00:00 2001 From: chenjie Date: Mon, 20 Apr 2026 20:50:07 +0800 Subject: [PATCH 17/17] fix(windows): prefer bundled npm for startup and updates Use the installer-bundled Node.js toolchain when resolving npm for WebUI startup and updater rebuilds so Windows ARM64 installs avoid cross-arch rollup failures. Made-with: Cursor --- flocks/cli/service_manager.py | 20 ++++++- flocks/updater/updater.py | 39 ++++++++++++- tests/cli/test_service_manager.py | 72 ++++++++++++++++++++++- tests/updater/test_updater.py | 97 +++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index f79b17ec..27f0972b 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -276,6 +276,24 @@ def resolve_node_executable() -> str | None: 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 = resolve_node_executable() @@ -816,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(): 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/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index d8d6c1af..9302ffba 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -85,6 +85,41 @@ def test_resolve_node_executable_falls_back_to_which_when_path_missing( 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: @@ -742,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) @@ -780,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/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,