param( [ValidateSet("server", "desktop", "docker", "all")] [string]$Target = "all", [ValidateSet("current", "windows", "linux", "macos")] [string]$Platform = "current", [switch]$SkipDockerBuild, [switch]$SkipDesktopBuild, [switch]$NoArchive, [switch]$Clean, [Alias("h", "?")] [switch]$Help ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $Root = (Resolve-Path (Join-Path $ScriptDir "..")).Path $DistDir = Join-Path $Root "dist" $ServerDistDir = Join-Path $DistDir "server" $DesktopDistDir = Join-Path $DistDir "desktop" $DockerDistDir = Join-Path $DistDir "docker" $TempDir = Join-Path $DistDir ".tmp" $PyInstallerDir = Join-Path $TempDir "pyinstaller" $FrontendDir = Join-Path $Root "frontend" $PackagingDir = Join-Path $Root "packaging" $ServerName = "capareport-server" $DesktopApiBase = "http://127.0.0.1:9081" function Write-Step($Message) { Write-Host "" Write-Host "==> $Message" -ForegroundColor Green } function Write-Info($Message) { Write-Host " $Message" } function Show-Usage { Write-Host "Usage: scripts\build.bat [server|desktop|docker|all] [options]" Write-Host "" Write-Host "Options:" Write-Host " -Platform current|windows|linux|macos" Write-Host " -NoArchive" Write-Host " -Clean" Write-Host " -SkipDockerBuild" Write-Host " -SkipDesktopBuild" } function Assert-InWorkspace($Path) { $resolved = (Resolve-Path -LiteralPath $Path).Path if (!$resolved.StartsWith($Root, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to operate outside workspace: $resolved" } return $resolved } function Remove-Tree($Path) { if (Test-Path -LiteralPath $Path) { $resolved = Assert-InWorkspace $Path Remove-Item -LiteralPath $resolved -Recurse -Force } } function Invoke-Checked($Command, $WorkingDirectory = $Root) { Write-Info $Command Push-Location $WorkingDirectory try { cmd /c $Command if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code $LASTEXITCODE`: $Command" } } finally { Pop-Location } } function Get-CurrentPlatform { if ($Platform -ne "current") { return $Platform } if ($env:OS -eq "Windows_NT") { return "windows" } $uname = (uname -s 2>$null) if ($uname -eq "Darwin") { return "macos" } return "linux" } function Get-ExecutableName($PlatformName) { if ($PlatformName -eq "windows") { return "$ServerName.exe" } return $ServerName } function Get-RustHostTriple { $output = rustc -vV foreach ($line in $output) { if ($line -like "host:*") { return $line.Substring(5).Trim() } } throw "Unable to detect Rust host triple" } function Test-TauriCli { cmd /c "cargo tauri --version >nul 2>nul" return $LASTEXITCODE -eq 0 } function Ensure-PythonEnvironment { Write-Step "Preparing Python environment" $venvPython = Join-Path $Root ".venv\Scripts\python.exe" if (!(Test-Path $venvPython)) { Invoke-Checked "uv venv" $Root } Invoke-Checked "uv pip install -r requirements.txt" $Root Invoke-Checked "uv pip install pyinstaller" $Root } function Build-Frontend($ApiBase) { Write-Step "Building frontend" if (!(Test-Path (Join-Path $FrontendDir "node_modules"))) { Invoke-Checked "npm ci" $FrontendDir } $previousApiBase = $env:VITE_API_BASE if ($ApiBase) { $env:VITE_API_BASE = $ApiBase } else { Remove-Item Env:\VITE_API_BASE -ErrorAction SilentlyContinue } try { Invoke-Checked "npm run build" $FrontendDir } finally { if ($null -ne $previousApiBase) { $env:VITE_API_BASE = $previousApiBase } else { Remove-Item Env:\VITE_API_BASE -ErrorAction SilentlyContinue } } } function Build-ServerBinary([switch]$OneFile) { Ensure-PythonEnvironment Write-Step "Building server binary" Remove-Tree $PyInstallerDir New-Item -ItemType Directory -Force -Path $PyInstallerDir | Out-Null $python = Join-Path $Root ".venv\Scripts\python.exe" $workPath = Join-Path $PyInstallerDir "work" $distPath = Join-Path $PyInstallerDir "dist" $specPath = Join-Path $PackagingDir "capareport-server.spec" $previousOneFile = $env:CAPAREPORT_ONEFILE if ($OneFile) { $env:CAPAREPORT_ONEFILE = "1" } else { Remove-Item Env:\CAPAREPORT_ONEFILE -ErrorAction SilentlyContinue } try { Invoke-Checked "`"$python`" -m PyInstaller --clean --noconfirm --workpath `"$workPath`" --distpath `"$distPath`" `"$specPath`"" $Root } finally { if ($null -ne $previousOneFile) { $env:CAPAREPORT_ONEFILE = $previousOneFile } else { Remove-Item Env:\CAPAREPORT_ONEFILE -ErrorAction SilentlyContinue } } if ($OneFile) { $binaryDir = $distPath $binaryPath = Join-Path $binaryDir (Get-ExecutableName (Get-CurrentPlatform)) if (!(Test-Path $binaryPath)) { throw "PyInstaller output not found: $binaryPath" } } else { $binaryDir = Join-Path $distPath $ServerName if (!(Test-Path $binaryDir)) { throw "PyInstaller output not found: $binaryDir" } } return $binaryDir } function Write-TextFile($Path, $Content, [switch]$Lf) { $parent = Split-Path -Parent $Path New-Item -ItemType Directory -Force -Path $parent | Out-Null if ($Lf) { [System.IO.File]::WriteAllText($Path, $Content.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) } else { [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) } } function Convert-ToNsisPath($Path) { return (Resolve-Path -LiteralPath $Path).Path } function Get-TauriNsisTemplatePath { $registryDir = Join-Path $env:USERPROFILE ".cargo\registry\src" if (!(Test-Path $registryDir)) { throw "Cargo registry not found: $registryDir" } $templates = Get-ChildItem -Path $registryDir -Recurse -Filter "installer.nsi" | Where-Object { $_.FullName -match "tauri-bundler-[^\\]+\\src\\bundle\\windows\\nsis\\installer\.nsi$" } | Sort-Object FullName -Descending if (!$templates) { throw "Tauri NSIS template not found. Run cargo tauri build once to fetch tauri-bundler." } return $templates[0].FullName } function New-DesktopNsisTemplate { $source = Get-TauriNsisTemplatePath $target = Join-Path $TempDir "tauri-nsis-installer.nsi" $hookPath = Convert-ToNsisPath (Join-Path $Root "src-tauri\windows\nsis-hooks.nsh") $content = [System.IO.File]::ReadAllText($source) $content = [regex]::Replace( $content, '\{\{#if installer_hooks\}\}\s*!include "\{\{installer_hooks\}\}"\s*\{\{/if\}\}', "!include `"$hookPath`"" ) $restoreLine = " Call RestorePreviousInstallLocation" $installDirOverride = @' Call RestorePreviousInstallLocation !if "${INSTALLMODE}" == "perMachine" ${If} ${FileExists} "D:\*.*" StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}" ${Else} ${If} ${RunningX64} StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" ${Else} StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" ${EndIf} ${EndIf} !endif '@ if (!$content.Contains($restoreLine)) { throw "Tauri NSIS template changed: RestorePreviousInstallLocation marker not found" } $content = $content.Replace($restoreLine, $installDirOverride) $onInitTail = @' !if "${INSTALLMODE}" == "both" !insertmacro MULTIUSER_INIT !endif FunctionEnd '@ $onInitOverride = @' !if "${INSTALLMODE}" == "both" !insertmacro MULTIUSER_INIT !endif !if "${INSTALLMODE}" == "perMachine" ${If} ${FileExists} "D:\*.*" StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}" ${Else} ${If} ${RunningX64} StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" ${Else} StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" ${EndIf} ${EndIf} !endif FunctionEnd '@ if (!$content.Contains($onInitTail)) { throw "Tauri NSIS template changed: .onInit tail marker not found" } $content = $content.Replace($onInitTail, $onInitOverride) $restoreFunction = @' Function RestorePreviousInstallLocation ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" StrCmp $4 "" +2 0 StrCpy $INSTDIR $4 FunctionEnd '@ $restoreOverride = @' Function RestorePreviousInstallLocation !if "${INSTALLMODE}" == "perMachine" ${If} ${FileExists} "D:\*.*" StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}" Return ${EndIf} !endif ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" StrCmp $4 "" +2 0 StrCpy $INSTDIR $4 FunctionEnd '@ if (!$content.Contains($restoreFunction)) { throw "Tauri NSIS template changed: RestorePreviousInstallLocation function marker not found" } $content = $content.Replace($restoreFunction, $restoreOverride) Write-TextFile $target $content -Lf return $target } function Invoke-WithDesktopTauriConfig($NsisTemplatePath, [scriptblock]$Action) { $configPath = Join-Path $Root "src-tauri\tauri.conf.json" $original = [System.IO.File]::ReadAllText($configPath) try { $config = $original | ConvertFrom-Json $config.bundle.windows.nsis | Add-Member -NotePropertyName "template" -NotePropertyValue $NsisTemplatePath -Force Write-TextFile $configPath ($config | ConvertTo-Json -Depth 50) & $Action } finally { [System.IO.File]::WriteAllText($configPath, $original, [System.Text.UTF8Encoding]::new($false)) } } function Copy-RuntimeFiles($OutputDir) { Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $OutputDir "Configure.json") -Force Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $OutputDir "ReportScript.sql") -Force New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "cache") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "logs") | Out-Null $frontendDist = Join-Path $FrontendDir "dist" if (!(Test-Path $frontendDist)) { throw "Frontend dist not found: $frontendDist" } New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "frontend") | Out-Null Copy-Item -Path $frontendDist -Destination (Join-Path $OutputDir "frontend") -Recurse -Force } function Write-ServerLaunchers($OutputDir, $PlatformName) { $exe = Get-ExecutableName $PlatformName $bat = @" @echo off setlocal cd /d "%~dp0" if not exist cache mkdir cache if not exist logs mkdir logs echo Starting CapacityReport server... echo URL: http://localhost:9081 "%~dp0$exe" --host 0.0.0.0 --port 9081 pause "@ Write-TextFile (Join-Path $OutputDir "run.bat") $bat $sh = @' #!/usr/bin/env sh set -eu SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" cd "$SCRIPT_DIR" mkdir -p cache logs chmod +x "./__SERVER_EXE__" 2>/dev/null || true echo "Starting CapacityReport server..." echo "URL: http://localhost:9081" exec "./__SERVER_EXE__" --host 0.0.0.0 --port 9081 '@ $sh = $sh.Replace("__SERVER_EXE__", $exe) Write-TextFile (Join-Path $OutputDir "start.sh") $sh -Lf } function Build-ServerPortable { $platformName = Get-CurrentPlatform if ($platformName -ne "windows" -and $env:OS -eq "Windows_NT") { Write-Host "Native $platformName portable packages must be built on $platformName." -ForegroundColor Yellow Write-Host "The Windows build will continue for the current host." -ForegroundColor Yellow $platformName = "windows" } $outputDir = Join-Path $ServerDistDir "CapacityReport-Server-$platformName-x64" $archive = "$outputDir.zip" Remove-Tree $outputDir Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue Build-Frontend $null $binaryDir = Build-ServerBinary New-Item -ItemType Directory -Force -Path $outputDir | Out-Null Copy-Item -Path (Join-Path $binaryDir "*") -Destination $outputDir -Recurse -Force Copy-RuntimeFiles $outputDir Write-ServerLaunchers $outputDir $platformName if (!$NoArchive) { Compress-Archive -Path (Join-Path $outputDir "*") -DestinationPath $archive -Force Write-Info "Archive: $archive" } Write-Info "Server package: $outputDir" } function Copy-DockerBundle { Remove-Tree $DockerDistDir New-Item -ItemType Directory -Force -Path $DockerDistDir | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $DockerDistDir "cache") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $DockerDistDir "logs") | Out-Null Copy-Item -Path (Join-Path $PackagingDir "docker-compose.yml") -Destination (Join-Path $DockerDistDir "docker-compose.yml") -Force Copy-Item -Path (Join-Path $PackagingDir "mysql") -Destination (Join-Path $DockerDistDir "mysql") -Recurse -Force Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $DockerDistDir "Configure.json") -Force Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $DockerDistDir "ReportScript.sql") -Force } function Build-DockerImage { Remove-Tree $DockerDistDir if ($SkipDockerBuild) { Write-Host "Docker image build skipped." Copy-DockerBundle Invoke-Checked "docker compose -f `"$DockerDistDir\docker-compose.yml`" config" $Root Write-Info "Docker package: $DockerDistDir" return } Write-Step "Building Docker image" Invoke-Checked "docker build --progress=plain -f `"$PackagingDir\Dockerfile`" -t capacity-report-app:latest ." $Root Write-Step "Packaging Docker output" Copy-DockerBundle Invoke-Checked "docker save -o `"$DockerDistDir\capacity-report-app-latest.tar`" capacity-report-app:latest" $Root Invoke-Checked "docker compose -f `"$DockerDistDir\docker-compose.yml`" config" $Root Write-Info "Docker package: $DockerDistDir" } function Build-DesktopPackage { if ($SkipDesktopBuild) { Write-Host "Desktop build skipped." return } if (!(Get-Command rustc -ErrorAction SilentlyContinue)) { throw "Rust is required for the Tauri desktop build" } Remove-Tree $DesktopDistDir Build-Frontend $DesktopApiBase $binaryDir = Build-ServerBinary -OneFile $platformName = Get-CurrentPlatform $exe = Get-ExecutableName $platformName $triple = Get-RustHostTriple $sidecarDir = Join-Path $Root "src-tauri\binaries" New-Item -ItemType Directory -Force -Path $sidecarDir | Out-Null $sidecarName = if ($platformName -eq "windows") { "$ServerName-$triple.exe" } else { "$ServerName-$triple" } Copy-Item -Path (Join-Path $binaryDir $exe) -Destination (Join-Path $sidecarDir $sidecarName) -Force if (!(Test-TauriCli)) { Write-Step "Installing Tauri CLI" Invoke-Checked "cargo install tauri-cli --locked" $Root } $nsisTemplate = New-DesktopNsisTemplate Invoke-WithDesktopTauriConfig $nsisTemplate { Remove-Tree (Join-Path $Root "src-tauri\target\release\bundle") Write-Step "Building Tauri desktop package" Invoke-Checked "cargo tauri build" (Join-Path $Root "src-tauri") } New-Item -ItemType Directory -Force -Path $DesktopDistDir | Out-Null $bundleDir = Join-Path $Root "src-tauri\target\release\bundle" Get-ChildItem -Path $bundleDir -Recurse -File -Include "*.msi", "*.exe" | Copy-Item -Destination $DesktopDistDir -Force Write-Info "Desktop package: $DesktopDistDir" } function Clean-Intermediates { Write-Step "Cleaning intermediate output" Remove-Tree $TempDir Remove-Tree (Join-Path $Root "frontend\dist") Remove-Tree (Join-Path $Root "src-tauri\binaries") Remove-Tree (Join-Path $Root "src-tauri\target") } if ($Help) { Show-Usage exit 0 } if ($Clean) { Write-Step "Cleaning dist output" Remove-Tree $DistDir Remove-Tree (Join-Path $Root "frontend\dist") Remove-Tree (Join-Path $Root "src-tauri\binaries") Remove-Tree (Join-Path $Root "src-tauri\target") } New-Item -ItemType Directory -Force -Path $DistDir | Out-Null switch ($Target) { "server" { Build-ServerPortable } "docker" { Build-DockerImage } "desktop" { Build-DesktopPackage } "all" { Build-ServerPortable Build-DockerImage Build-DesktopPackage } } Clean-Intermediates Write-Step "Build finished"