Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2672fd9
debug
chenjie-booker Apr 15, 2026
f8f8614
debug
chenjie-booker Apr 15, 2026
802d708
debug
chenjie-booker Apr 15, 2026
6dc2b86
fix(windows): keep installer artifacts out of git history
chenjie-booker Apr 17, 2026
c4647cf
packing wip
chenjie-booker Apr 17, 2026
7df7d3e
fix(windows): harden uninstall cleanup and script compatibility
chenjie-booker Apr 17, 2026
ffa12ae
Merge origin/main into cj_packing.
chenjie-booker Apr 17, 2026
a3f8570
fix(windows): cache packaging downloads and add Chrome fallback sources
chenjie-booker Apr 17, 2026
7dbfe38
fix(windows): harden packaged startup and runtime pinning
chenjie-booker Apr 18, 2026
f6b1670
fix(windows): escape postinstall script braces in installer
chenjie-booker Apr 18, 2026
127ab2e
fix(windows): avoid chrome sandbox probe and add uninstall run id
chenjie-booker Apr 18, 2026
27830f0
chore(ci): upgrade core GitHub actions to Node 24-ready versions
chenjie-booker Apr 18, 2026
9760231
fix(windows): make finish launch start flocks reliably
chenjie-booker Apr 18, 2026
1936047
fix(windows): remove finish autostart and clean install directory on …
chenjie-booker Apr 18, 2026
e635d55
fix(windows): warn when installer target is Program Files
chenjie-booker Apr 18, 2026
d0e72dc
fix(windows): restore Chinese finish hint wording for CI
chenjie-booker Apr 18, 2026
e8549fd
fix(windows): upgrade bundled uv for installer compatibility
chenjie-booker Apr 20, 2026
4e09b9d
fix(windows): prefer bundled npm for startup and updates
chenjie-booker Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions .github/workflows/windows-packaging-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Build the Windows installer and attach it to the GitHub Release for the pushed tag.

name: Windows packaging — release installer

on:
push:
tags:
- "v*"

permissions:
contents: write

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
release-asset:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Cache bundled tool downloads
uses: actions/cache@v5
with:
path: C:\Users\runneradmin\AppData\Local\flocks\cache
key: windows-flocks-cache-${{ hashFiles('packaging/windows/versions.manifest.json') }}
restore-keys: |
windows-flocks-cache-

- name: Install Inno Setup
shell: pwsh
run: |
choco install innosetup --no-progress -y

- name: Build installer
id: pack
shell: pwsh
run: |
$out = Join-Path $env:RUNNER_TEMP "flocks-staging"
$manifestPath = Join-Path "${{ github.workspace }}" "packaging/windows/versions.manifest.json"
$manifest = Get-Content -Path $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json
$tag = "${{ github.ref_name }}"
$appVersion = $tag.TrimStart('v')
& "${{ github.workspace }}/packaging/windows/build-installer.ps1" `
-OutputDir $out `
-RepoRoot "${{ github.workspace }}" `
-AppVersion $appVersion
$uvExe = Join-Path $out "tools/uv/uv.exe"
$nodeExe = Join-Path $out "tools/node/node.exe"
$chromeExe = Get-ChildItem -Path (Join-Path $out "tools/chrome") -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "chrome-win" } |
Select-Object -First 1
if (-not (Test-Path $uvExe)) {
throw "uv executable not found in staging: $uvExe"
}
if (-not (Test-Path $nodeExe)) {
throw "node executable not found in staging: $nodeExe"
}
if (-not $chromeExe) {
throw "chrome executable not found in staging under tools/chrome"
}
$uvVersion = (& $uvExe --version).Trim()
$nodeVersion = (& $nodeExe --version).Trim()
$chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.ProductVersion
if ([string]::IsNullOrWhiteSpace($chromeVersion)) {
$chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.FileVersion
}
if ([string]::IsNullOrWhiteSpace($chromeVersion)) {
throw "Failed to resolve bundled chrome version from file metadata"
}
Write-Host "[runtime] pinned uv version: $($manifest.uv.version)"
Write-Host "[runtime] bundled uv version: $uvVersion"
Write-Host "[runtime] pinned node version: $($manifest.nodejs.version)"
Write-Host "[runtime] bundled node version: $nodeVersion"
Write-Host "[runtime] pinned chrome version: $($manifest.chrome_for_testing.version)"
Write-Host "[runtime] bundled chrome version: $chromeVersion"
if ($uvVersion -notmatch ("^uv\s+" + [regex]::Escape($manifest.uv.version) + "(\s|$)")) {
throw "Bundled uv version does not match pinned version in manifest"
}
if ($nodeVersion -ne ("v" + $manifest.nodejs.version)) {
throw "Bundled node version does not match pinned version in manifest"
}
if ($chromeVersion -notmatch [regex]::Escape($manifest.chrome_for_testing.version)) {
throw "Bundled chrome version does not match pinned version in manifest"
}
$builtInstaller = Join-Path "${{ github.workspace }}" "packaging/windows/Output/FlocksSetup.exe"
if (-not (Test-Path $builtInstaller)) {
throw "Installer not found: $builtInstaller"
}
$name = "FlocksSetup-${tag}.exe"
$installer = Join-Path $env:RUNNER_TEMP $name
Copy-Item -Path $builtInstaller -Destination $installer -Force
Add-Content -Path $env:GITHUB_OUTPUT -Value "installer_path=$installer" -Encoding utf8NoBOM

- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.pack.outputs.installer_path }}
generate_release_notes: true
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
97 changes: 97 additions & 0 deletions .github/workflows/windows-packaging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Windows packaging (installer)

on:
workflow_dispatch:
pull_request:
paths:
- "packaging/windows/**"
- "scripts/install.ps1"
- "scripts/install_zh.ps1"
- "flocks/cli/service_manager.py"
- ".github/workflows/windows-packaging.yml"
- ".github/workflows/windows-packaging-release.yml"

permissions:
contents: read

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
installer:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Cache bundled tool downloads
uses: actions/cache@v5
with:
path: C:\Users\runneradmin\AppData\Local\flocks\cache
key: windows-flocks-cache-${{ hashFiles('packaging/windows/versions.manifest.json') }}
restore-keys: |
windows-flocks-cache-

- name: Install Inno Setup
shell: pwsh
run: |
choco install innosetup --no-progress -y

- name: Build installer
shell: pwsh
run: |
$out = Join-Path $env:RUNNER_TEMP "flocks-staging"
$manifestPath = Join-Path "${{ github.workspace }}" "packaging/windows/versions.manifest.json"
$manifest = Get-Content -Path $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json
& "${{ github.workspace }}/packaging/windows/build-installer.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}"
$uvExe = Join-Path $out "tools/uv/uv.exe"
$nodeExe = Join-Path $out "tools/node/node.exe"
$chromeExe = Get-ChildItem -Path (Join-Path $out "tools/chrome") -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "chrome-win" } |
Select-Object -First 1
if (-not (Test-Path $uvExe)) {
throw "uv executable not found in staging: $uvExe"
}
if (-not (Test-Path $nodeExe)) {
throw "node executable not found in staging: $nodeExe"
}
if (-not $chromeExe) {
throw "chrome executable not found in staging under tools/chrome"
}
$uvVersion = (& $uvExe --version).Trim()
$nodeVersion = (& $nodeExe --version).Trim()
$chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.ProductVersion
if ([string]::IsNullOrWhiteSpace($chromeVersion)) {
$chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.FileVersion
}
if ([string]::IsNullOrWhiteSpace($chromeVersion)) {
throw "Failed to resolve bundled chrome version from file metadata"
}
Write-Host "[runtime] pinned uv version: $($manifest.uv.version)"
Write-Host "[runtime] bundled uv version: $uvVersion"
Write-Host "[runtime] pinned node version: $($manifest.nodejs.version)"
Write-Host "[runtime] bundled node version: $nodeVersion"
Write-Host "[runtime] pinned chrome version: $($manifest.chrome_for_testing.version)"
Write-Host "[runtime] bundled chrome version: $chromeVersion"
if ($uvVersion -notmatch ("^uv\s+" + [regex]::Escape($manifest.uv.version) + "(\s|$)")) {
throw "Bundled uv version does not match pinned version in manifest"
}
if ($nodeVersion -ne ("v" + $manifest.nodejs.version)) {
throw "Bundled node version does not match pinned version in manifest"
}
if ($chromeVersion -notmatch [regex]::Escape($manifest.chrome_for_testing.version)) {
throw "Bundled chrome version does not match pinned version in manifest"
}
$exe = Join-Path "${{ github.workspace }}" "packaging/windows/Output/FlocksSetup.exe"
if (-not (Test-Path $exe)) {
throw "Installer not found: $exe"
}
Copy-Item -Path $exe -Destination (Join-Path $env:RUNNER_TEMP "FlocksSetup.exe") -Force

- name: Upload installer artifact
uses: actions/upload-artifact@v7
with:
name: flocks-windows-installer
path: ${{ runner.temp }}/FlocksSetup.exe
if-no-files-found: error
retention-days: 90
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ webui/.env.production.local
webui/.vite/deps/_metadata.json
.vite

# Windows packaging outputs
packaging/windows/Output/

.flocks/.storage_migrated
artifacts/
prompt.txt
Expand Down
69 changes: 67 additions & 2 deletions flocks/cli/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,58 @@ def resolve_flocks_cli_command(root: Path | None = None) -> list[str]:
return resolve_python_subprocess_command(root) + ["-m", "flocks.cli.main"]


def _bundled_node_install_dir() -> Path | None:
"""Return the bundled Node.js installation directory when available."""
candidates: list[str] = []
node_home = os.getenv("FLOCKS_NODE_HOME")
if node_home:
candidates.append(node_home)

install_root = os.getenv("FLOCKS_INSTALL_ROOT")
if install_root:
candidates.append(str(Path(install_root).expanduser() / "tools" / "node"))

for candidate in candidates:
node_dir = Path(candidate).expanduser()
if sys.platform == "win32":
node_executable = node_dir / "node.exe"
else:
node_executable = node_dir / "bin" / "node"
if node_executable.exists():
return node_dir.resolve()
return None


def resolve_node_executable() -> str | None:
"""Resolve node executable from bundled toolchain first, then PATH."""
node_dir = _bundled_node_install_dir()
if node_dir is not None:
node_executable = node_dir / ("node.exe" if sys.platform == "win32" else "bin/node")
return str(node_executable)
return which("node")


def resolve_npm_executable() -> str | None:
"""Resolve npm from bundled toolchain first, then PATH."""
node_dir = _bundled_node_install_dir()
if node_dir is not None:
candidates = (
[node_dir / "npm.cmd", node_dir / "npm", node_dir / "bin/npm"]
if sys.platform == "win32"
else [node_dir / "bin/npm", node_dir / "npm"]
)
for candidate in candidates:
if candidate.exists():
return str(candidate)

if sys.platform == "win32":
return which("npm.cmd") or which("npm")
return which("npm") or which("npm.cmd")


def get_node_major_version() -> int | None:
"""Return the detected Node.js major version."""
node = which("node")
node = resolve_node_executable()
if not node:
return None

Expand Down Expand Up @@ -785,7 +834,7 @@ def start_frontend(config: ServiceConfig, console) -> None:
if runtime_record is not None:
paths.frontend_pid.unlink(missing_ok=True)

npm = which("npm") or which("npm.cmd")
npm = resolve_npm_executable()
if not npm:
raise ServiceError("未检测到 npm,请先安装 Node.js 22+(包含 npm)后重试。")
if not node_version_satisfies_requirement():
Expand Down Expand Up @@ -1343,6 +1392,22 @@ def build_frontend_env(config: ServiceConfig) -> dict[str, str]:
"""Build frontend proxy environment variables from backend service settings."""
env = os.environ.copy()
env["FLOCKS_API_PROXY_TARGET"] = backend_access_base_url(config)

# When using the bundled toolchain (Windows installer), npm/node spawned by
# `npm run build/preview` must be able to locate the bundled node.exe via
# PATH — npm itself does not always inherit the caller's resolved executable
# location. Prepend the bundled node directory when present.
node_dir = _bundled_node_install_dir()
if node_dir is not None:
if sys.platform == "win32":
node_bin = str(node_dir)
else:
node_bin = str(node_dir / "bin")
path_sep = os.pathsep
current_path = env.get("PATH", "")
if node_bin not in current_path.split(path_sep):
env["PATH"] = node_bin + path_sep + current_path

return env


Expand Down
39 changes: 38 additions & 1 deletion flocks/updater/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Loading
Loading