netx/packaging/_common.ps1
oliver 97393cd407 Release 0.4.5: installer DB wizard, credential key, lean offline Setup.
Choose bundled or external PostgreSQL in the Setup wizard with connection
validation; optional NETX_CREDENTIAL_SECRET_KEY; single desktop shortcut;
faster uninstall and tray startup for offline Windows packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:44:40 +08:00

360 lines
12 KiB
PowerShell

# Shared path helpers for Windows packaging scripts.
# Dot-source: . "$PSScriptRoot\_common.ps1"
$ErrorActionPreference = "Stop"
function Get-NetxRepoRoot {
return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
function Get-NetxProgramRoot {
param([string]$Override = "")
if ($Override) { return (Resolve-Path $Override).Path }
if ($env:NETX_PROGRAM_ROOT) { return $env:NETX_PROGRAM_ROOT }
# Packaging scripts live in <program>\packaging when installed; in-repo they live under netx\packaging.
$parent = Split-Path -Parent $PSScriptRoot
return $parent
}
function Get-NetxDataRoot {
param(
[string]$ProgramRoot,
[string]$Override = ""
)
if ($Override) { return $Override }
if ($env:NETX_DATA_ROOT) { return $env:NETX_DATA_ROOT }
$programData = [Environment]::GetFolderPath("CommonApplicationData")
$defaultPd = Join-Path $programData "NetX"
# Portable layout: prefer sibling NetXData next to program root when marker exists or Program Files not writable.
$portable = Join-Path (Split-Path -Parent $ProgramRoot) "NetXData"
if (Test-Path (Join-Path $ProgramRoot ".portable")) {
return $portable
}
if (Test-Path $defaultPd) {
return $defaultPd
}
# Dev / first run from repo: use repo-local data packaging area under ProgramData if possible.
try {
if (-not (Test-Path $defaultPd)) {
New-Item -ItemType Directory -Path $defaultPd -Force -ErrorAction Stop | Out-Null
}
return $defaultPd
} catch {
return $portable
}
}
function Get-NetxVersion {
param([string]$ProgramRoot)
$vj = Join-Path $ProgramRoot "version.json"
if (Test-Path $vj) {
try {
$j = Get-Content -Raw -Path $vj | ConvertFrom-Json
if ($j.version) { return [string]$j.version }
} catch {}
}
$toml = Join-Path $ProgramRoot "pyproject.toml"
if (-not (Test-Path $toml)) {
$toml = Join-Path (Get-NetxRepoRoot) "pyproject.toml"
}
if (Test-Path $toml) {
$m = Select-String -Path $toml -Pattern '^\s*version\s*=\s*"([^"]+)"' | Select-Object -First 1
if ($m) { return $m.Matches[0].Groups[1].Value }
}
return "0.0.0"
}
function Read-DotEnv {
param([string]$Path)
$map = @{}
if (-not (Test-Path $Path)) { return $map }
Get-Content -Path $Path -Encoding utf8 | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#")) { return }
$i = $line.IndexOf("=")
if ($i -lt 1) { return }
$k = $line.Substring(0, $i).Trim()
$v = $line.Substring($i + 1).Trim()
if (($v.StartsWith('"') -and $v.EndsWith('"')) -or ($v.StartsWith("'") -and $v.EndsWith("'"))) {
$v = $v.Substring(1, $v.Length - 2)
}
$map[$k] = $v
}
return $map
}
function Write-DotEnvValue {
param(
[string]$Path,
[hashtable]$Values
)
$lines = @()
$seen = @{}
if (Test-Path $Path) {
Get-Content -Path $Path -Encoding utf8 | ForEach-Object {
$line = $_
$t = $line.Trim()
if ($t -and -not $t.StartsWith("#") -and $t.Contains("=")) {
$k = $t.Substring(0, $t.IndexOf("=")).Trim()
if ($Values.ContainsKey($k)) {
$lines += "$k=$($Values[$k])"
$seen[$k] = $true
return
}
}
$lines += $line
}
}
foreach ($k in $Values.Keys) {
if (-not $seen.ContainsKey($k)) {
$lines += "$k=$($Values[$k])"
}
}
$dir = Split-Path -Parent $Path
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -Path $Path -Value ($lines -join "`r`n") -Encoding utf8
}
function Get-DbMode {
param([hashtable]$EnvMap)
$m = ""
if ($EnvMap.ContainsKey("NETX_DB_MODE")) { $m = [string]$EnvMap["NETX_DB_MODE"] }
if (-not $m -and $env:NETX_DB_MODE) { $m = $env:NETX_DB_MODE }
$m = $m.Trim().ToLowerInvariant()
if ($m -eq "bundled") { return "bundled" }
return "external"
}
function Import-NetxEnvFile {
param([string]$Path)
$map = Read-DotEnv -Path $Path
foreach ($k in $map.Keys) {
Set-Item -Path "Env:$k" -Value $map[$k]
}
return $map
}
function ConvertTo-NetxFsPath([string]$Path) {
# Forward slashes work in .env and most Windows APIs via Python pathlib.
return ($Path -replace '\\', '/')
}
function Get-NetxDataEnvMap {
param([string]$DataRoot)
$d = $DataRoot
return @{
"NETX_AUTH_MCP_TOKEN_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\mcp_token"))
"NETX_AUTH_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\jwt_secret"))
"NETX_CREDENTIAL_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\credential_secret"))
"NETX_SCHEDULER_HEARTBEAT_PATH" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime\scheduler_heartbeat.json"))
"NETX_RUN_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime"))
"NETX_BIZ_STATE_SPOOL_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\biz_state_spool"))
"NETX_NE_COLLECTION_DATA_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\ne_collections"))
"NETX_NE_EXEC_JOB_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\ne_exec_jobs"))
"NETX_WEBCRT_DATA_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\webcrt"))
}
}
function New-NetxFernetKey {
# cryptography.fernet.Fernet.generate_key() == urlsafe_b64encode(os.urandom(32))
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return [Convert]::ToBase64String($bytes).Replace('+', '-').Replace('/', '_')
}
function Ensure-NetxDataDirectories {
param([string]$DataRoot)
$subs = @(
"data", "data\auth", "data\runtime", "data\biz_state_spool",
"data\ne_collections", "data\ne_exec_jobs", "data\webcrt",
"backups", "pgdata"
)
foreach ($sub in $subs) {
$p = Join-Path $DataRoot $sub
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null
}
}
}
function Test-NetxTcpPortFree {
param([string]$HostName = "127.0.0.1", [int]$Port)
try {
$client = New-Object System.Net.Sockets.TcpClient
$iar = $client.BeginConnect($HostName, $Port, $null, $null)
$ok = $iar.AsyncWaitHandle.WaitOne(400)
if ($ok -and $client.Connected) {
$client.Close()
return $false
}
$client.Close()
return $true
} catch {
return $true
}
}
function Test-NetxApiHealthy {
param([string]$HostName = "127.0.0.1", [int]$Port = 8890, [int]$TimeoutSec = 2)
try {
$url = "http://${HostName}:${Port}/health"
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $TimeoutSec -MaximumRedirection 0
if ($r.StatusCode -ne 200) { return $false }
$body = $r.Content | ConvertFrom-Json -ErrorAction Stop
return ($body.status -eq "ok")
} catch {
return $false
}
}
function Wait-NetxApiHealthy {
param(
[string]$HostName = "127.0.0.1",
[int]$Port = 8890,
[int]$TimeoutSec = 180,
[int]$PollMs = 500
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
if (Test-NetxApiHealthy -HostName $HostName -Port $Port -TimeoutSec 2) {
return $true
}
Start-Sleep -Milliseconds $PollMs
}
return $false
}
function ConvertTo-NetxProcessArgument {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
# Start-Process joins string[] args with spaces and does NOT quote values that
# contain spaces (e.g. C:\Program Files\NetX). Always emit one argv token.
if ($Value -match '[\s"]') {
return '"' + ($Value -replace '"', '\"') + '"'
}
return $Value
}
function ConvertTo-NetxProcessArgumentString {
param([Parameter(Mandatory = $true)][string[]]$Arguments)
return (($Arguments | ForEach-Object { ConvertTo-NetxProcessArgument -Value "$_" }) -join " ")
}
function Start-NetxPowerShell {
param(
[Parameter(Mandatory = $true)][string]$File,
[string[]]$Arguments = @(),
[string]$WorkingDirectory = "",
[ValidateSet("Hidden", "Normal", "Minimized")]
[string]$WindowStyle = "Hidden",
[switch]$Wait = $false,
[switch]$PassThru = $false
)
$parts = @(
"-NoProfile",
"-WindowStyle", $WindowStyle,
"-ExecutionPolicy", "Bypass",
"-File", $File
) + $Arguments
$sp = @{
FilePath = "powershell.exe"
ArgumentList = (ConvertTo-NetxProcessArgumentString -Arguments $parts)
WindowStyle = $WindowStyle
}
if ($WorkingDirectory) { $sp.WorkingDirectory = $WorkingDirectory }
if ($Wait) { $sp.Wait = $true }
if ($PassThru -or $Wait) { $sp.PassThru = $true }
return Start-Process @sp
}
function Get-NetxSystemPython {
# Prefer a real interpreter; skip the WindowsApps Store stub.
$candidates = @()
foreach ($cmd in @("python", "python3", "py")) {
$c = Get-Command $cmd -ErrorAction SilentlyContinue
if ($c -and $c.Source -and ($c.Source -notmatch '\\WindowsApps\\')) {
$candidates += $c.Source
}
}
foreach ($p in @(
"$env:LOCALAPPDATA\Python\pythoncore-3.14-64\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
"$env:ProgramFiles\Python312\python.exe",
"$env:ProgramFiles\Python311\python.exe"
)) {
if ($p -and (Test-Path $p)) { $candidates += $p }
}
foreach ($p in ($candidates | Select-Object -Unique)) {
try {
$v = & $p -c "import sys; print('%d.%d'%sys.version_info[:2])" 2>$null
if ($v -match '^(3\.(1[1-9]|[2-9]\d))$') { return $p }
} catch {}
}
return $null
}
function Ensure-NetxVenv {
param(
[Parameter(Mandatory = $true)][string]$ProgramRoot,
[switch]$ForcePip = $false
)
$venvPy = Join-Path $ProgramRoot ".venv\Scripts\python.exe"
$req = Join-Path $ProgramRoot "requirements.txt"
if ((Test-Path $venvPy) -and -not $ForcePip) {
return $venvPy
}
if (-not (Test-Path $venvPy)) {
$py = Get-NetxSystemPython
if (-not $py) {
throw "python_not_found: install Python 3.11+ (or ship Setup built with -CreateVenv)"
}
Write-Host "==> Creating .venv with $py"
$venvDir = Join-Path $ProgramRoot ".venv"
try {
& $py -m venv $venvDir
} catch {
throw "venv_create_failed: cannot write $venvDir (need admin / ship -CreateVenv). $($_.Exception.Message)"
}
if (-not (Test-Path $venvPy)) {
throw "venv_create_failed: missing $venvPy (Program Files may be read-only without elevation)"
}
$ForcePip = $true
}
if ($ForcePip) {
if (-not (Test-Path $req)) { throw "requirements_missing: $req" }
Write-Host "==> pip install -r requirements.txt"
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r $req
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
}
return $venvPy
}
function Stop-NetxTrayProcesses {
param([string]$ProgramRoot = "")
$hits = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and $_.CommandLine -match 'netx_tray\.ps1'
})
if ($ProgramRoot) {
$esc = [regex]::Escape($ProgramRoot)
$hits = @($hits | Where-Object { $_.CommandLine -match $esc -or $_.CommandLine -match 'netx_tray\.ps1' })
}
foreach ($p in $hits) {
try {
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
Write-Host "Stopped NetX tray PID=$($p.ProcessId)"
} catch {}
}
}
function ConvertTo-NetxSqlLiteral([string]$Value) {
# Single-quote escaping for PostgreSQL string literals.
return ($Value -replace "'", "''")
}