netx/packaging/setup_first_run.ps1
oliver 4e582dc4a5
Some checks failed
Release Windows / build (push) Has been cancelled
Release 0.4.7: ProgramData ACL fix and tray menu polish.
Allow non-admin reconfigure of .env, harden setup/tray flows, and refresh the context menu with hover selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 16:43:28 +08:00

371 lines
17 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

param(
[ValidateSet("bundled", "external", "")]
[string]$DbMode = "",
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[string]$ExternalDatabaseUrl = "",
[string]$ExternalDatabaseUrlFile = "",
[string]$CredentialSecretKey = "",
[string]$CredentialSecretKeyFile = "",
[string]$BundledPassword = "",
[int]$BundledPort = 15432,
[switch]$NonInteractive = $false,
[switch]$SkipExternalProbe = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
function T([string]$En, [string]$Zh) {
if ($zh) { return $Zh } else { return $En }
}
try {
Write-Host ("==> " + (T "Program root:" "程序目录:") + " $prog")
Write-Host ("==> " + (T "Data root:" "数据目录:") + " $data")
Write-Host ("==> " + (T "Env file:" "环境文件:") + " $envPath")
Ensure-NetxDataDirectories -DataRoot $data
$existing = Read-DotEnv -Path $envPath
if (-not $DbMode) {
if ($NonInteractive) {
if ($existing.ContainsKey("NETX_DB_MODE")) {
$DbMode = $existing["NETX_DB_MODE"]
} elseif ($existing.ContainsKey("NETX_DATABASE_URL")) {
$DbMode = "external"
} else {
$DbMode = "bundled"
}
} else {
Write-Host ""
Write-Host (T "Choose database mode:" "选择数据库模式:") -ForegroundColor Cyan
Write-Host (T " 1) bundled — NetX portable PostgreSQL (offline / default)" " 1) bundled — NetX 内置便携 PostgreSQL(可离线,默认)")
Write-Host (T " 2) external — existing PostgreSQL (you provide connection URL)" " 2) external — 已有外置 PostgreSQL(需填写连接串)")
Write-Host ""
$choice = Read-Host (T "Enter 1 or 2" "请输入 1 或 2")
if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" }
}
}
$DbMode = $DbMode.Trim().ToLowerInvariant()
if ($DbMode -ne "bundled" -and $DbMode -ne "external") {
throw "invalid_db_mode: $DbMode"
}
$values = @{}
$values["NETX_DB_MODE"] = $DbMode
$values["NETX_HOST"] = "127.0.0.1"
$values["NETX_PORT"] = "8890"
# Absolute UI path when present; else relative for source trees.
$distAbs = Join-Path $prog "web\dist"
if (Test-Path (Join-Path $distAbs "index.html")) {
$values["NETX_UI_DIST_DIR"] = (ConvertTo-NetxFsPath $distAbs)
} else {
$values["NETX_UI_DIST_DIR"] = "web/dist"
}
# All runtime data under data root (never under Program Files).
$dataEnv = Get-NetxDataEnvMap -DataRoot $data
foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] }
# Preload credential key from file/CLI only; interactive prompt comes AFTER DB settings
# so users are not left thinking the key alone finished setup.
if ($CredentialSecretKeyFile) {
if (-not (Test-Path -LiteralPath $CredentialSecretKeyFile)) {
throw "credential_secret_key_file_missing: $CredentialSecretKeyFile"
}
$CredentialSecretKey = [IO.File]::ReadAllText($CredentialSecretKeyFile).Trim()
}
if ($DbMode -eq "bundled") {
$pgsql = Join-Path $prog "postgres\pgsql"
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
$alt = Join-Path $PSScriptRoot "postgres\pgsql"
if (Test-Path (Join-Path $alt "bin\initdb.exe")) {
$pgsql = $alt
} else {
throw (T `
"bundled_postgres_missing: incomplete package (expected $pgsql). Offline Setup must include postgres; rebuild with build_release.ps1 on a machine that already ran download_postgres.ps1." `
"缺少内置 PostgreSQL(期望路径 $pgsql)。离线安装包必须自带数据库;请在已运行过 download_postgres.ps1 的机器上重新 build_release。")
}
}
if (-not $BundledPassword) {
if ($NonInteractive) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
} else {
$sec = Read-Host -Prompt (T "Password for bundled role 'netx' (empty = auto-generate)" "内置数据库用户 netx 的密码(回车=自动生成)") -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
try {
$BundledPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
if (-not $BundledPassword) {
$BundledPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
Write-Host (T "Generated password (saved in .env only)." "已自动生成密码(仅保存在 .env)。")
}
}
}
$pgData = Join-Path $data "pgdata"
$values["NETX_BUNDLED_PG_PORT"] = "$BundledPort"
$values["NETX_BUNDLED_PG_DATA_DIR"] = (ConvertTo-NetxFsPath $pgData)
$pwFile = Join-Path $data "pg_netx.pw"
Set-Content -Path $pwFile -Value $BundledPassword -Encoding ascii -NoNewline
$encPw = [uri]::EscapeDataString($BundledPassword)
$values["NETX_DATABASE_URL"] = "postgresql+psycopg://netx:${encPw}@127.0.0.1:${BundledPort}/netx"
$initdb = Join-Path $pgsql "bin\initdb.exe"
$pgCtl = Join-Path $pgsql "bin\pg_ctl.exe"
$psql = Join-Path $pgsql "bin\psql.exe"
$sqlPw = ConvertTo-NetxSqlLiteral $BundledPassword
if (-not (Test-Path (Join-Path $pgData "PG_VERSION"))) {
Write-Host "==> initdb $pgData"
$pwSuper = Join-Path $data "pg_super.pw"
Set-Content -Path $pwSuper -Value $BundledPassword -Encoding ascii -NoNewline
& $initdb -D $pgData -U postgres -A password --pwfile=$pwSuper -E UTF8 --locale=C
if ($LASTEXITCODE -ne 0) { throw "initdb_failed" }
}
$conf = Join-Path $pgData "postgresql.conf"
$hba = Join-Path $pgData "pg_hba.conf"
if (Test-Path $conf) {
$confText = Get-Content -Raw -Path $conf
if ($confText -notmatch "(?m)^\s*port\s*=") {
Add-Content -Path $conf -Value "`nport = $BundledPort`nlisten_addresses = '127.0.0.1'`n"
} else {
$confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $BundledPort"
if ($confText -notmatch "(?m)^\s*listen_addresses\s*=") {
$confText += "`nlisten_addresses = '127.0.0.1'`n"
} else {
$confText = $confText -replace "(?m)^\s*listen_addresses\s*=\s*'[^']*'", "listen_addresses = '127.0.0.1'"
}
Set-Content -Path $conf -Value $confText -Encoding utf8
}
}
if (Test-Path $hba) {
$hbaText = Get-Content -Raw -Path $hba
if ($hbaText -notmatch "127\.0\.0\.1/32") {
Add-Content -Path $hba -Value "`nhost all all 127.0.0.1/32 scram-sha-256`n"
}
}
$status = & $pgCtl -D $pgData status 2>&1 | Out-String
if ($status -notmatch "server is running") {
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $BundledPort)) {
throw "port_in_use: 127.0.0.1:$BundledPort already occupied (bundled Postgres)"
}
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
& $pgCtl -D $pgData -l (Join-Path $pgData "pg.log") start
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
Start-Sleep -Seconds 2
}
$env:PGPASSWORD = $BundledPassword
try {
function Invoke-NetxPsql {
param([string]$Sql, [string]$Database = "postgres")
$out = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d $Database -v ON_ERROR_STOP=1 -tAc $Sql 2>&1
if ($LASTEXITCODE -ne 0) {
throw "psql_failed ($LASTEXITCODE): $Sql`n$out"
}
return (($out | Out-String).Trim())
}
$role = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_roles WHERE rolname='netx'"
if ($role -eq "1") {
Invoke-NetxPsql -Sql "ALTER ROLE netx WITH LOGIN PASSWORD '$sqlPw'" | Out-Null
} else {
Invoke-NetxPsql -Sql "CREATE ROLE netx LOGIN PASSWORD '$sqlPw'" | Out-Null
}
$db = Invoke-NetxPsql -Sql "SELECT 1 FROM pg_database WHERE datname='netx'"
if ($db -ne "1") {
Invoke-NetxPsql -Sql "CREATE DATABASE netx OWNER netx" | Out-Null
}
Invoke-NetxPsql -Sql "GRANT ALL PRIVILEGES ON DATABASE netx TO netx" | Out-Null
# Verify login as app role (catches auth/hba mismatches early).
$prev = $env:PGPASSWORD
$env:PGPASSWORD = $BundledPassword
try {
$ping = & $psql -h 127.0.0.1 -p $BundledPort -U netx -d netx -v ON_ERROR_STOP=1 -tAc "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -ne "1") {
throw "netx_role_login_failed: $ping"
}
} finally {
$env:PGPASSWORD = $prev
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
} else {
if ($ExternalDatabaseUrlFile) {
if (-not (Test-Path -LiteralPath $ExternalDatabaseUrlFile)) {
throw "external_url_file_missing: $ExternalDatabaseUrlFile"
}
$ExternalDatabaseUrl = [IO.File]::ReadAllText($ExternalDatabaseUrlFile).Trim()
}
if (-not $ExternalDatabaseUrl) {
if ($NonInteractive) {
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} else {
throw "external_url_required"
}
} else {
# Interactive: always ask. Empty = keep existing URL (never silent).
Write-Host ""
Write-Host (T `
"Enter PostgreSQL URL (database/role must already exist):" `
"请输入 PostgreSQL 连接串(库和用户需事先建好):") -ForegroundColor Cyan
Write-Host " postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME"
Write-Host (T `
"Example: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx" `
"示例: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx")
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
Write-Host (T `
"Current: $($existing['NETX_DATABASE_URL'])" `
"当前: $($existing['NETX_DATABASE_URL'])") -ForegroundColor DarkGray
Write-Host (T `
"Press Enter to keep the current URL, or paste a new one." `
"直接回车 = 保留当前连接串;或粘贴新的连接串。")
}
$entered = (Read-Host "NETX_DATABASE_URL").Trim()
if ($entered) {
$ExternalDatabaseUrl = $entered
} elseif ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
Write-Host (T "==> Keeping existing NETX_DATABASE_URL" "==> 保留已有 NETX_DATABASE_URL") -ForegroundColor Green
}
}
}
if (-not $ExternalDatabaseUrl) {
throw "external_url_required"
}
$ExternalDatabaseUrl = $ExternalDatabaseUrl.Trim()
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
Write-Host "==> External Postgres configured" -ForegroundColor Green
if (-not $SkipExternalProbe) {
# Probe with bundled psql when available (wizard already tested; this covers CLI reconfigure).
$psqlProbe = Join-Path $prog "postgres\pgsql\bin\psql.exe"
$testHelper = Join-Path $PSScriptRoot "installer\test_pg_conn.ps1"
if (-not (Test-Path $testHelper)) {
$testHelper = Join-Path $PSScriptRoot "test_pg_conn.ps1"
}
if ((Test-Path $psqlProbe) -and ($ExternalDatabaseUrl -match '^(?:postgresql(?:\+psycopg)?|postgres)://([^:]+):([^@]*)@([^:/]+):?(\d+)?/([^?\s]+)')) {
$u = [uri]::UnescapeDataString($Matches[1])
$pw = [uri]::UnescapeDataString($Matches[2])
$h = $Matches[3]
$po = if ($Matches[4]) { [int]$Matches[4] } else { 5432 }
$dbn = [uri]::UnescapeDataString($Matches[5])
Write-Host "==> Probing external PostgreSQL ${h}:${po}/${dbn} ..."
if (Test-Path $testHelper) {
$probeErr = Join-Path $env:TEMP ("netx-pg-probe-" + [Guid]::NewGuid().ToString("n") + ".txt")
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testHelper `
-PgHost $h -Port $po -User $u -Password $pw -Database $dbn `
-PsqlPath $psqlProbe -OutErrFile $probeErr
if ($LASTEXITCODE -ne 0) {
$detail = if (Test-Path $probeErr) { (Get-Content -LiteralPath $probeErr -Raw) } else { "exit $LASTEXITCODE" }
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
throw (T "external_db_probe_failed: $detail" "外置数据库连接失败: $detail")
}
Remove-Item -LiteralPath $probeErr -Force -ErrorAction SilentlyContinue
Write-Host "==> External PostgreSQL OK" -ForegroundColor Green
} else {
$env:PGPASSWORD = $pw
try {
$ping = & $psqlProbe -h $h -p $po -U $u -d $dbn -t -A -c "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -notmatch '1') {
throw (T "external_db_probe_failed: $ping" "外置数据库连接失败: $ping")
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
} else {
Write-Host (T `
"[WARN] Skipped connection probe (no bundled psql or URL parse failed)." `
"[WARN] 已跳过连接探测(缺少捆绑 psql 或连接串无法解析)。") -ForegroundColor Yellow
}
}
}
# Fernet key AFTER database prompts (interactive UX).
# Priority: CLI/file > interactive prompt > existing .env > generate.
if (-not $CredentialSecretKey -and -not $NonInteractive) {
Write-Host ""
Write-Host (T `
"Optional: paste NETX_CREDENTIAL_SECRET_KEY from an existing install" `
"可选: 粘贴已有安装的 NETX_CREDENTIAL_SECRET_KEY(便于沿用已加密凭据)") -ForegroundColor Cyan
Write-Host (T `
"Leave empty to keep existing .env key, or auto-generate if none." `
"留空则保留已有 .env 中的密钥;若没有则自动生成。")
$CredentialSecretKey = (Read-Host "NETX_CREDENTIAL_SECRET_KEY").Trim()
}
if ($CredentialSecretKey) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $CredentialSecretKey.Trim()
Write-Host "==> Using provided NETX_CREDENTIAL_SECRET_KEY" -ForegroundColor Green
} elseif ($existing.ContainsKey("NETX_CREDENTIAL_SECRET_KEY") -and $existing["NETX_CREDENTIAL_SECRET_KEY"]) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $existing["NETX_CREDENTIAL_SECRET_KEY"]
Write-Host "==> Keeping existing NETX_CREDENTIAL_SECRET_KEY from .env" -ForegroundColor Green
} else {
$values["NETX_CREDENTIAL_SECRET_KEY"] = New-NetxFernetKey
Write-Host "==> Generated NETX_CREDENTIAL_SECRET_KEY (saved in .env)" -ForegroundColor Green
}
Write-DotEnvValue -Path $envPath -Values $values
Write-Host ""
Write-Host "Wrote $envPath" -ForegroundColor Green
# Official Setup ships python/runtime + .venv (build_release -CreateVenv).
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
try {
$null = Ensure-NetxVenv -ProgramRoot $prog
Write-Host "==> Bundled Python venv OK: $venvPy" -ForegroundColor Green
} catch {
if (Test-Path $venvPy) {
throw
}
Write-Host "==> Bundled .venv missing — creating one (Setup should normally ship it)." -ForegroundColor Yellow
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Install a Setup built with: packaging\build_release.ps1 -CreateVenv" -ForegroundColor Yellow
}
Write-Host ""
Write-Host (T "Setup complete." "首次配置完成。") -ForegroundColor Green
if (-not $NonInteractive) {
Write-Host (T `
"Next: Start Menu → NetX Tray (or press Y below)." `
"下一步: 开始菜单 → NetX 托盘 (或在下方按 Y 立即启动)。")
$ans = Read-Host (T "Start NetX tray now? [Y/n]" "现在启动 NetX 托盘?[Y/n]")
if ($ans -notmatch '^[Nn]') {
try {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-StartOnLaunch") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
Write-Host (T "Tray started." "托盘已启动。") -ForegroundColor Green
} catch {
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} else {
Write-Host "Next: .\packaging\start_netx_app.ps1 or NetX-Tray.cmd"
}
} catch {
Write-Host ""
Write-Host ("[ERR] " + $_.Exception.Message) -ForegroundColor Red
if (-not $NonInteractive) {
try {
[void](Read-Host (T "Press Enter to close" "按回车关闭窗口"))
} catch {}
}
exit 1
}