# WindsurfAPI 首次引导 + 前台监督启动。 # 幂等生成项目根 .env(UTF-8 无 BOM),填缺失键,打印面板地址,进入监督循环。 # 前台运行(可见日志 + Ctrl-C 优雅停),最利首次调试。开机自启后台见 install-task.bat。 $ErrorActionPreference = 'Stop' # 控制台按 UTF-8 输出,否则中文文案在默认 GBK/936 码页的 cmd 窗口里显示为乱码 # (脚本本身是 UTF-8 带 BOM,PowerShell 能正确读中文字面量,问题只在输出编码)。 try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path Set-Location $Root $EnvPath = Join-Path $Root '.env' # ── 1. 预检 Node ≥ 22.13 ─────────────────────────────────── function Stop-WithError([string]$msg, [string]$hint) { Write-Host '' Write-Host '============================================================' -ForegroundColor Red Write-Host " $msg" -ForegroundColor Red if ($hint) { Write-Host " $hint" -ForegroundColor Yellow } Write-Host '============================================================' -ForegroundColor Red Write-Host '按任意键退出...' $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') exit 1 } $node = Get-Command node -ErrorAction SilentlyContinue if (-not $node) { Stop-WithError 'Node.js 未安装或不在 PATH。' '请从 https://nodejs.org 安装 Node 22.13+ 后重试。' } $verRaw = (& node -v) -replace '^v', '' $nodeVer = [version]$verRaw if ($nodeVer -lt [version]'22.13.0') { Stop-WithError "Node 版本过低(当前 v$verRaw,需 ≥ 22.13)。" '请升级 Node.js 到 22.13 或更高。' } # ── 2. 强随机密钥(RandomNumberGenerator,非 Get-Random)────── function New-Secret([int]$bytes = 24) { $buf = New-Object 'System.Byte[]' $bytes $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() $rng.GetBytes($buf) $rng.Dispose() # base64url:去掉 +/= 让密钥能安全放进 .env / URL / header return ([Convert]::ToBase64String($buf)) -replace '\+', '-' -replace '/', '_' -replace '=', '' } # ── 3. 幂等读取现有 .env(仅填缺失/占位键,保留用户已设值)── $existing = @{} $existingLines = @() if (Test-Path $EnvPath) { try { $existingLines = Get-Content -Path $EnvPath -Encoding UTF8 foreach ($line in $existingLines) { $t = $line.Trim() if (-not $t -or $t.StartsWith('#')) { continue } $eq = $t.IndexOf('=') if ($eq -lt 1) { continue } $k = $t.Substring(0, $eq).Trim() $v = $t.Substring($eq + 1).Trim() $existing[$k] = $v } } catch { # 无法解析:备份后重写(不丢用户数据)。 $bak = "$EnvPath.bak.$(Get-Date -Format 'yyyyMMddHHmmss')" Copy-Item $EnvPath $bak -Force Write-Host "现有 .env 无法解析,已备份到 $bak" -ForegroundColor Yellow $existing = @{} } } function Need-Key([string]$key) { return (-not $existing.ContainsKey($key)) -or [string]::IsNullOrWhiteSpace($existing[$key]) } $generated = @{} $genApiKey = $null $genPassword = $null if (Need-Key 'PORT') { $generated['PORT'] = '3003' } # HOST=127.0.0.1 必需:默认 0.0.0.0 + 空 API_KEY = fail-closed 锁死。 if (Need-Key 'HOST') { $generated['HOST'] = '127.0.0.1' } if (Need-Key 'API_KEY') { $genApiKey = 'sk-windsurf-' + (New-Secret 24); $generated['API_KEY'] = $genApiKey } if (Need-Key 'DASHBOARD_PASSWORD') { $genPassword = New-Secret 18; $generated['DASHBOARD_PASSWORD'] = $genPassword } # DEVIN_CONNECT=1 必需:language_server 仅 Linux/macOS。 if ((Need-Key 'DEVIN_CONNECT') -and (Need-Key 'DEVIN_ONLY')) { $generated['DEVIN_CONNECT'] = '1' } if ($generated.Count -gt 0) { # 合并:保留现有行,追加缺失键。 $outLines = New-Object System.Collections.Generic.List[string] if ($existingLines.Count -gt 0) { foreach ($l in $existingLines) { $outLines.Add($l) } } if ($outLines.Count -gt 0 -and $outLines[$outLines.Count - 1].Trim() -ne '') { $outLines.Add('') } $outLines.Add("# --- generated by deploy/windows/start.ps1 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ---") foreach ($k in $generated.Keys) { $outLines.Add("$k=$($generated[$k])") } # ⚠️ 必须无 BOM 写(UTF8Encoding($false))。虽然 config.js 的 .trim() 恰好能 # 剥掉首键 BOM,仍以无 BOM 为规范,避免任何边缘键被 BOM 污染。 # try/catch:写盘失败(.env 被占用 / 目录只读)必须友好报错 + 暂停,绝不让 # 双击窗口静默闪退(对齐 KiroStudio start.ps1 的 Stop-WithError 保护)。 try { $text = ($outLines -join "`r`n") + "`r`n" [System.IO.File]::WriteAllText($EnvPath, $text, (New-Object System.Text.UTF8Encoding($false))) # Windows 上 .env 默认继承目录 ACL(Users / Authenticated Users 可读),而它 # 存着 API_KEY 与 DASHBOARD_PASSWORD。best-effort 收紧为仅当前用户:失败只 # 警告 —— 密钥已经写成功,不能因此让启动失败。 $aclUser = if ($env:USERDOMAIN) { "$env:USERDOMAIN\$env:USERNAME" } else { $env:USERNAME } try { & icacls $EnvPath /inheritance:r /grant:r "${aclUser}:(F)" *> $null if ($LASTEXITCODE -ne 0) { Write-Warning "icacls 未能收紧 .env 权限(退出码 $LASTEXITCODE);其他本机用户可能仍可读取。" } } catch { Write-Warning "icacls 未能收紧 .env 权限:$($_.Exception.Message)" } Write-Host ".env 已更新(填入 $($generated.Count) 个缺失键)。" -ForegroundColor Green } catch { Stop-WithError "写入 .env 失败:$($_.Exception.Message)" ` '常见原因:.env 被其它程序占用(已在运行的网关/编辑器),或目录只读。请关闭占用程序后重试。' } } else { Write-Host '.env 已存在且完整,未改动。' -ForegroundColor Green } # ── 4. 端口预检(更快的友好提示;server.js 也会自重试 10x3s)── $port = if ($generated.ContainsKey('PORT')) { $generated['PORT'] } elseif ($existing.ContainsKey('PORT')) { $existing['PORT'] } else { '3003' } try { $listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() if ($listeners | Where-Object { $_.Port -eq [int]$port }) { Write-Host "警告:端口 $port 已被占用,启动可能失败(server.js 会重试 ~30s)。" -ForegroundColor Yellow } } catch { } # git 缺失告警(自更新需要) if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Host '提示:未检测到 git,面板 Update / update.bat 自更新将不可用。' -ForegroundColor Yellow } # ── 5. 打印面板地址 + 密钥 ───────────────────────────────── $hostVal = if ($generated.ContainsKey('HOST')) { $generated['HOST'] } elseif ($existing.ContainsKey('HOST')) { $existing['HOST'] } else { '127.0.0.1' } $displayHost = if ($hostVal -eq '0.0.0.0') { '127.0.0.1' } else { $hostVal } Write-Host '' if ($genPassword -or $genApiKey) { # 大字框:首次生成的密钥务必醒目,提示用户截图保存(对齐 KiroStudio 模板)。 Write-Host '################################################################' -ForegroundColor Green Write-Host '# #' -ForegroundColor Green Write-Host '# 已为你自动生成密钥(请截图妥善保存) #' -ForegroundColor Green Write-Host '# #' -ForegroundColor Green Write-Host '################################################################' -ForegroundColor Green Write-Host '' if ($genPassword) { Write-Host ' 面板登录密码 (DASHBOARD_PASSWORD):' -ForegroundColor Yellow Write-Host " $genPassword" -ForegroundColor White Write-Host '' } if ($genApiKey) { Write-Host ' 网关调用密钥 (API_KEY,给 Claude Code / SDK 用):' -ForegroundColor Yellow Write-Host " $genApiKey" -ForegroundColor White Write-Host '' } Write-Host ' 这些密钥已写入项目根 .env,下次启动无需重设。' -ForegroundColor DarkGray Write-Host '' } Write-Host '============================================================' -ForegroundColor Cyan Write-Host ' WindsurfAPI 已引导完成' -ForegroundColor Cyan Write-Host " >> 面板地址(浏览器打开,用 DASHBOARD_PASSWORD 登录):" -ForegroundColor Cyan Write-Host " http://${displayHost}:$port/dashboard" -ForegroundColor White if (-not $genPassword -and -not $genApiKey) { Write-Host ' (密钥沿用现有 .env,如需查看请打开项目根 .env)' -ForegroundColor DarkGray } if ($hostVal -eq '0.0.0.0') { Write-Host ' [提示] HOST=0.0.0.0:网关对局域网开放,请确保 API_KEY 为强随机值。' -ForegroundColor Yellow } Write-Host ' >> 登录后到 accounts 页添加一个 Windsurf/Devin session token 即可使用。' -ForegroundColor DarkGray Write-Host '============================================================' -ForegroundColor Cyan Write-Host '' # ── 6. 进入前台监督循环 ──────────────────────────────────── & (Join-Path $PSScriptRoot 'run.ps1')