diff --git a/CHANGELOG.md b/CHANGELOG.md index aa05c33..3b75041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。 +## Unreleased + +### Added + +- 增加统一的 Windows PowerShell 启动与更新脚本,提供固定的生产配置、Git 快进更新、锁定依赖重装和构建后启动流程。 +- 增加 Windows Server 非 Docker 部署、启动和更新说明。 + ## 0.0.5 - 2026-08-07 ### Changed diff --git a/README.zh-CN.md b/README.zh-CN.md index 8be66d0..35f2799 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -109,6 +109,43 @@ docker compose down 健康检查地址:`http://服务器地址:8080/healthz` +## Windows Server 部署(不使用 Docker) + +要求安装 Git、Node.js 22.12.0 或更高版本,并使用 PowerShell 5.1 或更高版本。以下示例假设项目位于 `C:\Services\WebTools`。 + +首次克隆后安装依赖并构建: + +```powershell +Set-Location C:\Services\WebTools +npm.cmd ci --include=dev +npm.cmd run build +``` + +生产构建成功后,使用项目根目录的统一脚本启动: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start +``` + +默认监听 `0.0.0.0:9000`,导航配置保存在 `C:\ProgramData\WebTools\data\navigation.json`。可以通过参数覆盖: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start ` + -ListenAddress 127.0.0.1 ` + -Port 8080 ` + -DataDir C:\ProgramData\WebTools\data +``` + +从 Git `main` 分支更新时,先在运行窗口按 `Ctrl+C` 停止服务,再执行: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update +``` + +更新脚本只接受干净的 Git 工作区和当前 `main` 分支,使用 `git pull --ff-only origin main` 避免隐式合并。随后执行 `npm ci --include=dev`、生产构建并自动重新启动服务。`npm ci` 会先删除现有 `node_modules` 再按 `package-lock.json` 完整重建,不需要手工删除旧依赖。只更新和构建、不立即启动时增加 `-NoStart`。 + +脚本不会读取、覆盖或删除 `C:\ProgramData\WebTools\data`。PowerShell 窗口关闭后前台服务会停止;如使用 Windows 计划任务,应让任务执行 `web-tools.ps1 start`,更新时停止任务、运行 `web-tools.ps1 update -NoStart`,然后重新启动任务。 + ## 工具管理 管理入口不显示在页面中,直接访问: diff --git a/web-tools.ps1 b/web-tools.ps1 new file mode 100644 index 0000000..1c9ddf7 --- /dev/null +++ b/web-tools.ps1 @@ -0,0 +1,180 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('start', 'update')] + [string]$Action = 'start', + + [ValidateNotNullOrEmpty()] + [string]$ListenAddress = '0.0.0.0', + + [ValidateRange(1, 65535)] + [int]$Port = 9000, + + [ValidateNotNullOrEmpty()] + [string]$DataDir = 'C:\ProgramData\WebTools\data', + + [ValidateNotNullOrEmpty()] + [string]$Remote = 'origin', + + [ValidateNotNullOrEmpty()] + [string]$Branch = 'main', + + [switch]$NoStart +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$projectRoot = $PSScriptRoot +$serverEntry = Join-Path $projectRoot 'server\index.mjs' +$distIndex = Join-Path $projectRoot 'dist\index.html' + +function Resolve-Application { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $command) { + throw "Required command '$Name' was not found in PATH." + } + + return $command.Source +} + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Executable, + + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + & $Executable @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Command '$Executable $($Arguments -join ' ')' failed with exit code $exitCode." + } +} + +function Assert-ProductionBuild { + if (-not (Test-Path -LiteralPath $serverEntry -PathType Leaf)) { + throw "Server entry was not found: $serverEntry" + } + + if (-not (Test-Path -LiteralPath $distIndex -PathType Leaf)) { + throw "Production build was not found: $distIndex. Run '.\web-tools.ps1 update' first." + } +} + +function Update-WebTools { + $git = Resolve-Application 'git.exe' + $npm = Resolve-Application 'npm.cmd' + + Push-Location $projectRoot + try { + $insideWorkTree = @(& $git rev-parse --is-inside-work-tree) + if ($LASTEXITCODE -ne 0 -or ($insideWorkTree -join '').Trim() -ne 'true') { + throw "Project directory is not a Git work tree: $projectRoot" + } + + $statusLines = @(& $git status --porcelain --untracked-files=normal) + if ($LASTEXITCODE -ne 0) { + throw "Unable to inspect the Git work tree." + } + if ($statusLines.Count -gt 0) { + throw "The Git work tree contains local changes. Update was stopped to avoid overwriting them:`n$($statusLines -join "`n")" + } + + $currentBranchOutput = @(& $git branch --show-current) + if ($LASTEXITCODE -ne 0) { + throw "Unable to determine the current Git branch." + } + $currentBranch = ($currentBranchOutput -join '').Trim() + if ($currentBranch -ne $Branch) { + throw "Current branch is '$currentBranch'; expected '$Branch'." + } + + Write-Host "Updating $Remote/$Branch..." + Invoke-NativeCommand $git @('pull', '--ff-only', $Remote, $Branch) + + $previousNodeEnv = [Environment]::GetEnvironmentVariable('NODE_ENV', 'Process') + try { + [Environment]::SetEnvironmentVariable('NODE_ENV', $null, 'Process') + + Write-Host 'Installing locked dependencies (npm ci replaces node_modules automatically)...' + Invoke-NativeCommand $npm @('ci', '--include=dev') + + Write-Host 'Building the production application...' + Invoke-NativeCommand $npm @('run', 'build') + } + finally { + [Environment]::SetEnvironmentVariable('NODE_ENV', $previousNodeEnv, 'Process') + } + + Assert-ProductionBuild + Write-Host 'Update and production build completed successfully.' + } + finally { + Pop-Location + } +} + +function Start-WebTools { + Assert-ProductionBuild + $node = Resolve-Application 'node.exe' + $dataDirectory = (New-Item -ItemType Directory -Force -Path $DataDir).FullName + + $previousEnvironment = @{ + NODE_ENV = [Environment]::GetEnvironmentVariable('NODE_ENV', 'Process') + HOST = [Environment]::GetEnvironmentVariable('HOST', 'Process') + PORT = [Environment]::GetEnvironmentVariable('PORT', 'Process') + DATA_DIR = [Environment]::GetEnvironmentVariable('DATA_DIR', 'Process') + } + + try { + [Environment]::SetEnvironmentVariable('NODE_ENV', 'production', 'Process') + [Environment]::SetEnvironmentVariable('HOST', $ListenAddress, 'Process') + [Environment]::SetEnvironmentVariable('PORT', $Port.ToString(), 'Process') + [Environment]::SetEnvironmentVariable('DATA_DIR', $dataDirectory, 'Process') + + Write-Host "Starting Web Tools on http://${ListenAddress}:$Port" + Write-Host "Navigation config: $(Join-Path $dataDirectory 'navigation.json')" + Write-Host 'Press Ctrl+C to stop.' + + Push-Location $projectRoot + try { + & $node $serverEntry + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Web Tools exited with code $exitCode." + } + } + finally { + Pop-Location + } + } + finally { + foreach ($name in $previousEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], 'Process') + } + } +} + +if ($Action -eq 'start' -and $NoStart) { + throw "-NoStart can only be used with the 'update' action." +} + +if ($Action -eq 'update') { + Update-WebTools + if ($NoStart) { + return + } +} + +Start-WebTools