Add Windows packaging with bundled UI and dual-mode PostgreSQL.

Ship installable releases via zip/Inno Setup with optional bundled Postgres, API-hosted SPA, manual update path, and CI workflow for future tag builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-06 22:52:03 +08:00
parent 8bdaaaacbf
commit 477ec4b120
24 changed files with 1332 additions and 12 deletions

View file

@ -113,6 +113,15 @@ NETX_UME_NOTIFICATION_TOPIC=ALARM
# NETX_OCLAW_FORWARD_MAX_RETRIES=3
# NETX_UME_RAW_JSON_MAX_BYTES=65536
# NETX_NE_COLLECTION_KEEP_DAYS=14
# Optional: serve built UI from API (same origin). Default web/dist; empty disables.
# NETX_UI_DIST_DIR=web/dist
# --- Windows packaging only (Linux ignores; always use NETX_DATABASE_URL) ---
# NETX_DB_MODE=external
# bundled | external; unset = external (compatible with existing deploys)
# NETX_BUNDLED_PG_PORT=15432
# NETX_BUNDLED_PG_DATA_DIR=
# NETX_UPDATE_URL=
# NETX_UPDATE_CHANNEL=stable
# Heavier fleets: raise CLI/DB together; also ensure Postgres max_connections and bastion session limits.
# One-click start (recommended): .\scripts\start_netx.ps1 -Background -WithWeb
# -> API + netx_api.worker + N× netx_api.biz_state_worker + optional Vite

48
.github/workflows/release-windows.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: Release Windows
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build release zip
shell: pwsh
run: |
powershell -ExecutionPolicy Bypass -File ./packaging/build_release.ps1 -CreateVenv
- name: Install Inno Setup
shell: pwsh
run: |
choco install innosetup -y --no-progress
echo "C:\Program Files (x86)\Inno Setup 6" >> $env:GITHUB_PATH
- name: Compile installer
shell: pwsh
run: |
$ver = (Get-Content pyproject.toml | Select-String '^\s*version\s*=').Line -replace '.*"(.+)"','$1'
ISCC.exe "/DMyAppVersion=$ver" packaging/installer/netx.iss
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
packaging/release/NetX-*-win64.zip
packaging/release/NetX-Setup-*.exe
generate_release_notes: true

5
.gitignore vendored
View file

@ -16,3 +16,8 @@ data/ne_collections/
data/webcrt/
data/auth/
data/runtime/
# Windows packaging artifacts (binaries / release trees)
packaging/cache/
packaging/release/
packaging/postgres/pgsql/
*.exe

View file

@ -33,3 +33,4 @@
- Validate after restart:
- `GET /v1/integrations/status` returns all major components as `up`.
- Keep parser config and importer changes versioned and reviewed before release.
- **Windows packaged installs:** program under `%ProgramFiles%\NetX`, data under `%ProgramData%\NetX` (see [packaging/README.md](packaging/README.md)). Prefer `update_netx.ps1` or Setup upgrade so `pgdata` / `.env` are preserved. Linux continues to manage Postgres separately.

View file

@ -156,6 +156,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\stop_netx.ps1 -Force
Primary web UI (Vite): `http://127.0.0.1:5173/`
API base: `http://127.0.0.1:8890/`
After `npm run build` in `web/`, the API can also serve the UI at `http://127.0.0.1:8890/` (same origin). See `NETX_UI_DIST_DIR` in `.env.example`.
### Windows installer / portable package (optional)
Fool-proof Windows delivery (bundled or external Postgres, program/data split, manual update) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`.
### 6) MCP(Cursor / oclaw / Claude)
先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。

View file

@ -35,6 +35,18 @@ _PUBLIC_PREFIXES = (
"/assets",
)
# Authenticated API / infra only. Everything else (SPA routes, favicon, …) is public.
def _is_api_auth_path(path: str) -> bool:
p = str(path or "")
if p.startswith("/v1/"):
return True
if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"):
return True
if p.startswith("/docs") or p.startswith("/redoc"):
return True
return False
# While must_change_password is true, only these authenticated endpoints are allowed.
_PASSWORD_CHANGE_ALLOW = frozenset(
{
@ -60,7 +72,12 @@ def _is_public(path: str) -> bool:
or p.startswith("/redoc")
):
return True
return any(p.startswith(pref) for pref in _PUBLIC_PREFIXES)
if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES):
return True
# Bundled UI routes (/login, /topology, …) must load without a token.
if not _is_api_auth_path(p):
return True
return False
def _client_ip(request: Request) -> str:

View file

@ -23,6 +23,8 @@ class Settings(BaseSettings):
source_type: str = "gateway_export_excel"
parser_config: str = "netx_api/config/parsers/zte_alarm_monitor_v1.yaml"
frontend_url: str = "http://127.0.0.1:5173"
# Built Vite UI (web/dist). Empty / missing → API-only root JSON (dev with Vite).
ui_dist_dir: str = "web/dist"
oclaw_analyze_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync"
oclaw_analyze_token: str = ""
oclaw_health_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/health"

View file

@ -95,8 +95,13 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/")
def root() -> dict:
from .ui_static import mount_ui_if_present # noqa: E402
_UI_MOUNTED = mount_ui_if_present(app)
if not _UI_MOUNTED:
@app.get("/")
def root() -> dict:
return {
"ok": True,
"mode": "api_only",

84
netx_api/ui_static.py Normal file
View file

@ -0,0 +1,84 @@
"""Optional SPA hosting from a Vite build (web/dist)."""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from starlette.responses import Response
from .config import settings
_log = logging.getLogger("netx.ui")
# Paths that must never fall through to index.html.
_API_PREFIXES = (
"/v1/",
"/docs",
"/redoc",
"/openapi.json",
"/metrics",
"/health",
)
def resolve_ui_dist() -> Path | None:
raw = str(getattr(settings, "ui_dist_dir", "") or "").strip()
if not raw:
return None
p = Path(raw)
if not p.is_absolute():
p = Path(__file__).resolve().parents[1] / p
index = p / "index.html"
if index.is_file():
return p
return None
def _is_api_or_infra(path: str) -> bool:
p = path or "/"
if p == "/health" or p.startswith("/health/"):
return True
if p in ("/metrics", "/metrics/json", "/openapi.json"):
return True
return any(p == pref.rstrip("/") or p.startswith(pref) for pref in _API_PREFIXES)
def mount_ui_if_present(app: FastAPI) -> bool:
"""Mount /assets + SPA fallback when ui_dist_dir has index.html. Returns True if mounted."""
dist = resolve_ui_dist()
if dist is None:
return False
assets = dist / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=str(assets)), name="ui_assets")
index_path = dist / "index.html"
@app.get("/")
async def spa_root() -> FileResponse:
return FileResponse(index_path)
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)
async def spa_fallback(full_path: str, request: Request) -> Response:
# Let API / infra 404s stay as API 404s (this route is last).
path = "/" + (full_path or "").lstrip("/")
if _is_api_or_infra(path):
return Response(status_code=404, content=b'{"detail":"Not Found"}', media_type="application/json")
# Prefer real files under dist (favicon, robots, etc.)
candidate = dist / full_path
try:
candidate.resolve().relative_to(dist.resolve())
except ValueError:
return FileResponse(index_path)
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(index_path)
_log.info("UI mounted from %s", dist)
return True

97
packaging/README.md Normal file
View file

@ -0,0 +1,97 @@
# NetX Windows packaging
**Linux is unchanged:** keep using your own PostgreSQL and `NETX_DATABASE_URL` with `scripts/start_netx.sh`. Nothing under this folder is required on Linux.
This directory builds a Windows deliverable with:
- Optional **bundled** portable PostgreSQL **or** **external** existing Postgres
- API-hosted UI (`web/dist`) — no separate Vite process for end users
- Program / data split so upgrades do not wipe the database
- Manual update script + auto-update **manifest contract** (fetch not implemented yet)
## What users get
| Artifact | How |
|----------|-----|
| `NetX-x.y.z-win64.zip` | `build_release.ps1` |
| `NetX-Setup-x.y.z.exe` | Compile `installer/netx.iss` with [Inno Setup](https://jrsoftware.org/isinfo.php) after staging |
## Build (developer machine)
Prerequisites: Python 3.11+, Node 20+, PowerShell, network (to download PG binaries once).
```powershell
cd netx
# Optional: download portable Postgres into packaging\postgres\pgsql
powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1
# Build web + stage + zip (-CreateVenv ships a ready .venv; large)
powershell -ExecutionPolicy Bypass -File .\packaging\build_release.ps1 -CreateVenv
```
Output:
- `packaging/release/netx-win64/` — stage tree
- `packaging/release/NetX-<ver>-win64.zip`
Inno Setup:
```text
ISCC.exe packaging\installer\netx.iss
```
Override version in the `.iss` or edit `#define MyAppVersion`.
## End-user install
### Setup.exe
1. Run `NetX-Setup-x.y.z.exe` (admin).
2. Files → `%ProgramFiles%\NetX\` (program root).
3. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
4. Optional post-install task runs `setup_first_run.ps1` (choose bundled vs external DB).
5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI**.
### Zip (portable)
1. Unpack anywhere.
2. Marker `.portable` → data root is sibling `NetXData\`.
3. Target needs **Python 3.11+** on PATH unless the zip was built with `-CreateVenv`.
4. Run:
```powershell
.\packaging\setup_first_run.ps1
.\packaging\start_netx_app.ps1
```
## Database modes
| Mode | Behavior |
|------|----------|
| `bundled` | Start portable PG on `127.0.0.1:15432`, data in data-root `pgdata\` |
| `external` | Do not start PG; use `NETX_DATABASE_URL` (same as today / Linux) |
| unset | Treated as **external** |
Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never set `NETX_DB_MODE=bundled` unless you want the portable engine.
## Manual update
```powershell
.\packaging\update_netx.ps1 -PackagePath .\NetX-0.3.0-win64.zip
```
Stops services, replaces program folders (`netx_api`, `web`, `packaging`, `postgres`, …), **keeps** the data root, restarts. Schema migrations still run via Alembic on API start.
## Auto-update (reserved)
See `manifest.example.json`. Future: set `NETX_UPDATE_URL` + `NETX_UPDATE_CHANNEL`; a checker will download the zip and call `update_netx.ps1`. Not implemented in this phase.
## Layout reminder
```
Program root (replaceable) Data root (never wiped by update)
netx_api\ web\dist\ .env
postgres\pgsql\ pgdata\ (bundled only)
packaging\ scripts\ data\auth\ data\runtime\
version.json backups\
```

137
packaging/_common.ps1 Normal file
View file

@ -0,0 +1,137 @@
# 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
}

123
packaging/build_release.ps1 Normal file
View file

@ -0,0 +1,123 @@
param(
[string]$Version = "",
[string]$OutDir = "",
[switch]$SkipWebBuild = $false,
[switch]$SkipPostgresDownload = $false,
[switch]$SkipZip = $false,
[switch]$CreateVenv = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$repo = Get-NetxRepoRoot
if (-not $Version) {
$Version = Get-NetxVersion -ProgramRoot $repo
}
$stage = if ($OutDir) { $OutDir } else { Join-Path $PSScriptRoot "release\netx-win64" }
Write-Host "==> Building NetX Windows release $Version"
Write-Host " Repo: $repo"
Write-Host " Stage: $stage"
if (Test-Path $stage) {
Remove-Item -Recurse -Force $stage
}
New-Item -ItemType Directory -Path $stage -Force | Out-Null
$webRoot = Join-Path $repo "web"
$dist = Join-Path $webRoot "dist"
if (-not $SkipWebBuild) {
if (-not (Get-Command npm.cmd -ErrorAction SilentlyContinue)) {
throw "npm_not_found"
}
Write-Host "==> npm build"
if (-not (Test-Path (Join-Path $webRoot "node_modules"))) {
& npm.cmd install --prefix $webRoot
if ($LASTEXITCODE -ne 0) { throw "npm_install_failed" }
}
& npm.cmd run build --prefix $webRoot
if ($LASTEXITCODE -ne 0) { throw "web_build_failed" }
}
if (-not (Test-Path (Join-Path $dist "index.html"))) {
throw "web_dist_missing: build web/ or drop -SkipWebBuild"
}
$pgsql = Join-Path $PSScriptRoot "postgres\pgsql"
if (-not $SkipPostgresDownload) {
if (-not (Test-Path (Join-Path $pgsql "bin\pg_ctl.exe"))) {
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "download_postgres.ps1")
}
}
# Flat layout = same as repo so scripts\start_netx.ps1 works unchanged.
foreach ($d in @("netx_api", "alembic", "scripts")) {
Copy-Item -Path (Join-Path $repo $d) -Destination (Join-Path $stage $d) -Recurse -Force
}
foreach ($f in @("pyproject.toml", "requirements.txt", "alembic.ini", "README.md", "LICENSE", "CONTRIBUTING.md", "SECURITY.md")) {
$src = Join-Path $repo $f
if (Test-Path $src) {
Copy-Item -Path $src -Destination (Join-Path $stage $f) -Force
}
}
$webOut = Join-Path $stage "web\dist"
New-Item -ItemType Directory -Path (Split-Path $webOut -Parent) -Force | Out-Null
Copy-Item -Path $dist -Destination $webOut -Recurse -Force
$packOut = Join-Path $stage "packaging"
New-Item -ItemType Directory -Path $packOut -Force | Out-Null
Copy-Item -Path (Join-Path $PSScriptRoot "_common.ps1") -Destination $packOut -Force
foreach ($name in @(
"download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1",
"stop_netx_app.ps1", "update_netx.ps1", "build_release.ps1",
"README.md", "manifest.example.json"
)) {
$src = Join-Path $PSScriptRoot $name
if (Test-Path $src) { Copy-Item $src (Join-Path $packOut $name) -Force }
}
Copy-Item -Path (Join-Path $PSScriptRoot "config") -Destination (Join-Path $packOut "config") -Recurse -Force
Copy-Item -Path (Join-Path $PSScriptRoot "installer") -Destination (Join-Path $packOut "installer") -Recurse -Force
New-Item -ItemType Directory -Path (Join-Path $packOut "postgres") -Force | Out-Null
Copy-Item -Path (Join-Path $PSScriptRoot "postgres\README.md") -Destination (Join-Path $packOut "postgres\README.md") -Force
if (Test-Path (Join-Path $pgsql "bin\pg_ctl.exe")) {
Write-Host "==> Copying bundled PostgreSQL"
New-Item -ItemType Directory -Path (Join-Path $stage "postgres") -Force | Out-Null
Copy-Item -Path $pgsql -Destination (Join-Path $stage "postgres\pgsql") -Recurse -Force
}
$builtAt = (Get-Date).ToUniversalTime().ToString("o")
@{
version = $Version
channel = "stable"
min_data_layout = 1
built_at = $builtAt
platform = "win64"
} | ConvertTo-Json | Set-Content -Path (Join-Path $stage "version.json") -Encoding utf8
Set-Content -Path (Join-Path $stage ".portable") -Value "1" -Encoding ascii
if ($CreateVenv) {
Write-Host "==> Creating .venv in stage (requires Python 3.11+ on PATH)"
$py = (Get-Command python -ErrorAction SilentlyContinue)
if (-not $py) { throw "python_not_found_for_venv" }
& $py.Source -m venv (Join-Path $stage ".venv")
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install --upgrade pip
& (Join-Path $stage ".venv\Scripts\python.exe") -m pip install -r (Join-Path $stage "requirements.txt")
}
Write-Host "==> Stage ready: $stage" -ForegroundColor Green
if (-not $SkipZip) {
$zip = Join-Path (Split-Path -Parent $stage) "NetX-$Version-win64.zip"
if (Test-Path $zip) { Remove-Item -Force $zip }
Compress-Archive -Path $stage -DestinationPath $zip -Force
Write-Host "==> Zip: $zip" -ForegroundColor Green
}
Write-Host ""
Write-Host "Ship options:"
Write-Host " Zip: user unpacks, runs packaging\setup_first_run.ps1 then start_netx_app.ps1"
Write-Host " Exe: compile packaging\installer\netx.iss with Inno Setup (needs stage above)"
Write-Host "Python: install 3.11+ on target, or rebuild with -CreateVenv and ship .venv"

View file

@ -0,0 +1,9 @@
# NETX_DB_MODE=bundled
# NETX_DATABASE_URL=postgresql+psycopg://netx:CHANGE_ME@127.0.0.1:15432/netx
# NETX_BUNDLED_PG_PORT=15432
# NETX_BUNDLED_PG_DATA_DIR=
# NETX_HOST=127.0.0.1
# NETX_PORT=8890
# NETX_UI_DIST_DIR=web/dist
# NETX_UPDATE_URL=
# NETX_UPDATE_CHANNEL=stable

View file

@ -0,0 +1,57 @@
param(
[string]$Version = "16.4-1",
[string]$OutDir = ""
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
# EnterpriseDB binary zip (Windows x86-64). Override with -Version if the URL 404s.
$ver = $Version
$url = "https://get.enterprisedb.com/postgresql/postgresql-$ver-windows-x64-binaries.zip"
$cache = if ($OutDir) { $OutDir } else { Join-Path $PSScriptRoot "cache" }
$zipPath = Join-Path $cache "postgresql-$ver-windows-x64-binaries.zip"
$extractRoot = Join-Path $cache "pgsql-$ver"
$destPgsql = Join-Path $PSScriptRoot "postgres\pgsql"
if (-not (Test-Path $cache)) {
New-Item -ItemType Directory -Path $cache -Force | Out-Null
}
Write-Host "==> PostgreSQL binaries URL: $url"
if (-not (Test-Path $zipPath)) {
Write-Host "==> Downloading (large; may take several minutes)..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
} else {
Write-Host "==> Using cached zip: $zipPath"
}
if (Test-Path $extractRoot) {
Remove-Item -Recurse -Force $extractRoot
}
New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null
Write-Host "==> Extracting..."
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
# Zip usually contains a top-level "pgsql" folder.
$found = Get-ChildItem -Path $extractRoot -Recurse -Filter "pg_ctl.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $found) {
throw "pg_ctl.exe not found after extract; check zip layout"
}
$pgsqlSrc = Split-Path -Parent (Split-Path -Parent $found.FullName)
Write-Host "==> Found pgsql tree: $pgsqlSrc"
if (Test-Path $destPgsql) {
Remove-Item -Recurse -Force $destPgsql
}
New-Item -ItemType Directory -Path (Split-Path -Parent $destPgsql) -Force | Out-Null
Copy-Item -Path $pgsqlSrc -Destination $destPgsql -Recurse -Force
$pgCtl = Join-Path $destPgsql "bin\pg_ctl.exe"
if (-not (Test-Path $pgCtl)) {
throw "install_failed: missing $pgCtl"
}
Write-Host "==> Bundled PostgreSQL ready: $destPgsql" -ForegroundColor Green
Write-Host " Tip: data directory is NOT here; setup_first_run / start scripts use the NetX data root."

View file

@ -0,0 +1,69 @@
; NetX Windows installer (Inno Setup 6+)
; Compile after: packaging\build_release.ps1
; ISCC.exe packaging\installer\netx.iss
#define MyAppName "NetX"
#ifndef MyAppVersion
#define MyAppVersion "0.3.0"
#endif
#define MyAppPublisher "NetX"
#define MyAppURL "https://github.com/hansjone/netx"
#define MyAppExeName "packaging\start_netx_app.ps1"
; Stage directory produced by build_release.ps1 (relative to this .iss)
#define StageDir "..\release\netx-win64"
[Setup]
AppId={{A7E3C2D1-9F40-4B8E-9C1A-NETXWIN64001}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
DefaultDirName={autopf}\NetX
DefaultGroupName=NetX
DisableProgramGroupPage=yes
LicenseFile={#StageDir}\LICENSE
OutputDir=..\release
OutputBaseFilename=NetX-Setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
; Data lives under {commonappdata}\NetX — not overwritten by upgrades
CloseApplications=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked
Name: "firstrun"; Description: "Run first-time setup (database mode) after install"; GroupDescription: "Setup:"; Flags: checkedonce
[Files]
; Entire release stage → {app}. Exclude .portable so installed builds use ProgramData.
Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: ".portable"
[Dirs]
Name: "{commonappdata}\NetX"
Name: "{commonappdata}\NetX\data"
Name: "{commonappdata}\NetX\data\auth"
Name: "{commonappdata}\NetX\data\runtime"
Name: "{commonappdata}\NetX\pgdata"
Name: "{commonappdata}\NetX\backups"
[Icons]
Name: "{group}\Start NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"
Name: "{group}\Stop NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\stop_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"
Name: "{group}\Open NetX UI"; Filename: "http://127.0.0.1:8890/"
Name: "{group}\First-time setup"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"
Name: "{autodesktop}\Start NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Tasks: desktopicon
[Run]
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Flags: postinstall skipifsilent; Tasks: firstrun
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Description: "Start NetX now"; Flags: postinstall nowait skipifsilent unchecked
[UninstallDelete]
; Do NOT delete {commonappdata}\NetX — preserves DB and secrets across reinstall
Type: filesandordirs; Name: "{app}"

View file

@ -0,0 +1,11 @@
{
"channel": "stable",
"latest": "0.3.0",
"min_compatible": "0.3.0",
"notes_url": "",
"windows": {
"url": "https://example.com/netx/NetX-0.2.0-win64.zip",
"sha256": "REPLACE_WITH_SHA256",
"size": 0
}
}

View file

@ -0,0 +1,17 @@
# Windows packaging — portable PostgreSQL binaries
Place an extracted EnterpriseDB Windows x64 PostgreSQL tree here as `pgsql/` so that:
```
packaging/postgres/pgsql/bin/pg_ctl.exe
packaging/postgres/pgsql/bin/initdb.exe
packaging/postgres/pgsql/bin/psql.exe
```
Do not commit the binaries. Use:
```powershell
powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1
```
Bundled mode uses a separate data directory under the NetX **data root** (never under `pgsql/`), so upgrading the binary tree does not wipe the database.

View file

@ -0,0 +1,101 @@
param(
[string]$Version = "",
[switch]$SkipBuild = $false,
[switch]$SkipInstaller = $false,
[switch]$SkipGitHub = $false,
[switch]$Draft = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$repo = Get-NetxRepoRoot
if (-not $Version) {
$Version = Get-NetxVersion -ProgramRoot $repo
}
$tag = "v$Version"
$releaseDir = Join-Path $PSScriptRoot "release"
$zip = Join-Path $releaseDir "NetX-$Version-win64.zip"
$setup = Join-Path $releaseDir "NetX-Setup-$Version.exe"
Write-Host "==> NetX publish $tag"
if (-not $SkipBuild) {
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "build_release.ps1") `
-Version $Version -CreateVenv
}
if (-not (Test-Path $zip)) {
throw "missing_zip: $zip"
}
if (-not $SkipInstaller) {
$isccCmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue
$isccCandidates = @(
$(if ($isccCmd) { $isccCmd.Source }),
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe"
) | Where-Object { $_ -and (Test-Path $_) }
$iscc = $isccCandidates | Select-Object -First 1
if (-not $iscc) {
Write-Host "[WARN] Inno Setup (ISCC) not found. Install: winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
Write-Host " Skipping Setup.exe; zip-only release."
} else {
Write-Host "==> Compiling installer: $iscc"
& $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss")
if (-not (Test-Path $setup)) {
throw "installer_build_failed: expected $setup"
}
Write-Host "==> Setup.exe: $setup" -ForegroundColor Green
}
}
if ($SkipGitHub) {
Write-Host "==> Skip GitHub release (-SkipGitHub)"
exit 0
}
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "gh_cli_not_found"
}
Set-Location $repo
$existing = gh release view $tag 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "==> Release $tag exists; uploading assets"
gh release upload $tag $zip --clobber
if (Test-Path $setup) {
gh release upload $tag $setup --clobber
}
} else {
$notes = @"
## NetX $Version — first Windows installable release
### Downloads
- **NetX-Setup-$Version.exe** — recommended installer (Program Files + ProgramData)
- **NetX-$Version-win64.zip** — portable directory package
### Requirements
- Windows 10/11 x64
- No separate Python or PostgreSQL install required (bundled in package)
### Quick start (installer)
1. Run ``NetX-Setup-$Version.exe`` as administrator.
2. Complete first-time setup (choose **bundled** or **external** PostgreSQL).
3. Start menu → **Start NetX** → browser opens ``http://127.0.0.1:8890/``
4. Default login: ``admin`` / ``admin123`` (change after first login)
### Linux
Unchanged — use your own PostgreSQL and ``scripts/start_netx.sh``.
See [packaging/README.md](https://github.com/hansjone/netx/blob/main/packaging/README.md) for details.
"@
$args = @("release", "create", $tag, "--title", "NetX $Version", "--notes", $notes)
if ($Draft) { $args += "--draft" }
$args += $zip
if (Test-Path $setup) { $args += $setup }
& gh @args
}
Write-Host "==> Published $tag" -ForegroundColor Green
gh release view $tag --web 2>$null

View file

@ -0,0 +1,185 @@
param(
[ValidateSet("bundled", "external", "")]
[string]$DbMode = "",
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[string]$ExternalDatabaseUrl = "",
[string]$BundledPassword = "",
[int]$BundledPort = 15432,
[switch]$NonInteractive = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
Write-Host "==> Program root: $prog"
Write-Host "==> Data root: $data"
Write-Host "==> Env file: $envPath"
if (-not (Test-Path $data)) {
New-Item -ItemType Directory -Path $data -Force | Out-Null
}
foreach ($sub in @("data", "data\auth", "data\runtime", "backups", "pgdata")) {
$p = Join-Path $data $sub
if (-not (Test-Path $p)) {
New-Item -ItemType Directory -Path $p -Force | Out-Null
}
}
$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 "Choose database mode:"
Write-Host " 1) bundled — use NetX portable PostgreSQL (default for new installs)"
Write-Host " 2) external — connect to an existing PostgreSQL (Linux / already deployed)"
$choice = Read-Host "Enter 1 or 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"
$values["NETX_UI_DIST_DIR"] = "web/dist"
# Point runtime data dirs into the data root (absolute).
$values["NETX_AUTH_MCP_TOKEN_FILE"] = ((Join-Path $data "data\auth\mcp_token") -replace '\\', '/')
$values["NETX_SCHEDULER_HEARTBEAT_PATH"] = ((Join-Path $data "data\runtime\scheduler_heartbeat.json") -replace '\\', '/')
if ($DbMode -eq "bundled") {
$pgsql = Join-Path $prog "postgres\pgsql"
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
# In-repo layout
$alt = Join-Path $PSScriptRoot "postgres\pgsql"
if (Test-Path (Join-Path $alt "bin\initdb.exe")) {
$pgsql = $alt
} else {
throw "bundled_postgres_missing: run packaging\download_postgres.ps1 first (expected $pgsql)"
}
}
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 "Password for bundled role 'netx' (empty = auto-generate)" -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 "Generated password (saved in .env only)."
}
}
}
$pgData = Join-Path $data "pgdata"
$values["NETX_BUNDLED_PG_PORT"] = "$BundledPort"
$values["NETX_BUNDLED_PG_DATA_DIR"] = ($pgData -replace '\\', '/')
$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"
# Initialize cluster if needed
$initdb = Join-Path $pgsql "bin\initdb.exe"
$pgCtl = Join-Path $pgsql "bin\pg_ctl.exe"
$psql = Join-Path $pgsql "bin\psql.exe"
$createdb = Join-Path $pgsql "bin\createdb.exe"
$createuser = Join-Path $pgsql "bin\createuser.exe"
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" }
}
# Ensure listen / port in postgresql.conf
$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"
}
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"
}
}
Write-Host "==> Starting bundled PostgreSQL for bootstrap"
& $pgCtl -D $pgData -l (Join-Path $data "pgdata\pg.log") start
Start-Sleep -Seconds 2
$env:PGPASSWORD = $BundledPassword
try {
$role = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='netx'"
if ($role -notmatch "1") {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "CREATE ROLE netx LOGIN PASSWORD '$BundledPassword';"
} else {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "ALTER ROLE netx WITH LOGIN PASSWORD '$BundledPassword';"
}
$db = & $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='netx'"
if ($db -notmatch "1") {
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "CREATE DATABASE netx OWNER netx;"
}
& $psql -h 127.0.0.1 -p $BundledPort -U postgres -d postgres -v ON_ERROR_STOP=1 `
-c "GRANT ALL PRIVILEGES ON DATABASE netx TO netx;"
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
} else {
if (-not $ExternalDatabaseUrl) {
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} elseif ($NonInteractive) {
throw "external_url_required"
} else {
$ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL (postgresql+psycopg://user:pass@host:5432/netx)"
}
}
if (-not $ExternalDatabaseUrl) {
throw "external_url_required"
}
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
Write-Host "==> External Postgres configured (ensure role/db exist; see scripts\init_pg.ps1)" -ForegroundColor Green
}
Write-DotEnvValue -Path $envPath -Values $values
Write-Host ""
Write-Host "Wrote $envPath" -ForegroundColor Green
Write-Host "Next: .\packaging\start_netx_app.ps1"

View file

@ -0,0 +1,112 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$SkipBrowser = $false,
[switch]$InlineSchedulers = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
if (-not (Test-Path $envPath)) {
Write-Host "[ERR] Missing $envPath — run setup_first_run.ps1 first." -ForegroundColor Red
exit 1
}
if (-not (Test-Path (Join-Path $prog "netx_api"))) {
throw "netx_api not found under program root: $prog"
}
$map = Import-NetxEnvFile -Path $envPath
Set-Location $prog
$env:PYTHONPATH = $prog
# Keep runtime artifacts in the data root (not under Program Files).
$env:NETX_AUTH_MCP_TOKEN_FILE = Join-Path $data "data\auth\mcp_token"
$env:NETX_SCHEDULER_HEARTBEAT_PATH = Join-Path $data "data\runtime\scheduler_heartbeat.json"
$env:NETX_AUTH_SECRET_FILE = Join-Path $data "data\auth\jwt_secret"
$dist = Join-Path $prog "web\dist"
if (Test-Path (Join-Path $dist "index.html")) {
$env:NETX_UI_DIST_DIR = $dist
}
$mode = Get-DbMode -EnvMap $map
Write-Host "==> Program: $prog"
Write-Host "==> Data: $data"
Write-Host "==> DB mode: $mode"
function Get-PgsqlBin {
foreach ($b in @(
(Join-Path $prog "postgres\pgsql\bin"),
(Join-Path $PSScriptRoot "postgres\pgsql\bin")
)) {
if (Test-Path (Join-Path $b "pg_ctl.exe")) { return $b }
}
return $null
}
if ($mode -eq "bundled") {
$pgBin = Get-PgsqlBin
if (-not $pgBin) { throw "bundled_postgres_missing" }
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) {
$map["NETX_BUNDLED_PG_DATA_DIR"]
} else {
Join-Path $data "pgdata"
}
$pgCtl = Join-Path $pgBin "pg_ctl.exe"
$status = & $pgCtl -D $pgData status 2>&1 | Out-String
if ($status -notmatch "server is running") {
Write-Host "==> Starting bundled PostgreSQL"
if (-not (Test-Path $pgData)) {
New-Item -ItemType Directory -Path $pgData -Force | Out-Null
}
$log = Join-Path $pgData "pg.log"
& $pgCtl -D $pgData -l $log start
if ($LASTEXITCODE -ne 0) { throw "pg_start_failed" }
Start-Sleep -Seconds 2
} else {
Write-Host "==> Bundled PostgreSQL already running"
}
}
# Ensure venv exists for start_netx.ps1
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
if (-not (Test-Path $venvPy)) {
Write-Host "==> Creating .venv (one-time)"
$pyCmd = Get-Command python -ErrorAction SilentlyContinue
if (-not $pyCmd) { throw "python_not_found: install Python 3.11+ and re-run" }
& $pyCmd.Source -m venv (Join-Path $prog ".venv")
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r (Join-Path $prog "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
}
$hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" }
$port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 }
$startScript = Join-Path $prog "scripts\start_netx.ps1"
if (-not (Test-Path $startScript)) {
throw "start_netx.ps1 not found at $startScript"
}
$psArgs = @(
"-ExecutionPolicy", "Bypass", "-File", $startScript,
"-SkipInstall", "-Background",
"-BindHost", $hostBind, "-Port", "$port"
)
if ($InlineSchedulers) { $psArgs += "-InlineSchedulers" }
Write-Host "==> Starting NetX (API + workers; UI via API on :$port)"
& powershell @psArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$url = "http://${hostBind}:${port}/"
Write-Host "==> Open: $url" -ForegroundColor Green
if (-not $SkipBrowser) {
try { Start-Process $url } catch {}
}

View file

@ -0,0 +1,46 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$KeepPostgres = $false
)
$ErrorActionPreference = "Continue"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
$map = @{}
if (Test-Path $envPath) {
$map = Read-DotEnv -Path $envPath
}
$stopScript = Join-Path $prog "scripts\stop_netx.ps1"
if (-not (Test-Path $stopScript)) {
$stopScript = Join-Path (Get-NetxRepoRoot) "scripts\stop_netx.ps1"
}
if (Test-Path $stopScript) {
Write-Host "==> Stopping NetX processes"
& powershell -ExecutionPolicy Bypass -File $stopScript -Force
} else {
Write-Host "[WARN] stop_netx.ps1 not found"
}
$mode = Get-DbMode -EnvMap $map
if ($mode -eq "bundled" -and -not $KeepPostgres) {
$pgBinCandidates = @(
(Join-Path $prog "postgres\pgsql\bin\pg_ctl.exe"),
(Join-Path $PSScriptRoot "postgres\pgsql\bin\pg_ctl.exe")
)
$pgCtl = $null
foreach ($c in $pgBinCandidates) {
if (Test-Path $c) { $pgCtl = $c; break }
}
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) { $map["NETX_BUNDLED_PG_DATA_DIR"] } else { Join-Path $data "pgdata" }
if ($pgCtl -and (Test-Path $pgData)) {
Write-Host "==> Stopping bundled PostgreSQL"
& $pgCtl -D $pgData stop -m fast
}
}
Write-Host "==> Stop done"

104
packaging/update_netx.ps1 Normal file
View file

@ -0,0 +1,104 @@
param(
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[string]$ProgramRoot = "",
[string]$DataRoot = "",
[switch]$SkipBackup = $false,
[switch]$NoStart = $false
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
if (-not (Test-Path $PackagePath)) {
throw "package_not_found: $PackagePath"
}
$work = Join-Path $env:TEMP ("netx-update-" + [guid]::NewGuid().ToString("n"))
New-Item -ItemType Directory -Path $work -Force | Out-Null
try {
Write-Host "==> Extracting update package"
if ($PackagePath.ToLowerInvariant().EndsWith(".zip")) {
Expand-Archive -Path $PackagePath -DestinationPath $work -Force
} else {
throw "unsupported_package: use a .zip built by build_release.ps1"
}
$src = $work
# If zip has a single top-level folder, use it.
$kids = @(Get-ChildItem -Path $work -Directory)
if ($kids.Count -eq 1 -and (Test-Path (Join-Path $kids[0].FullName "version.json"))) {
$src = $kids[0].FullName
} elseif (-not (Test-Path (Join-Path $work "version.json"))) {
$nested = Get-ChildItem -Path $work -Recurse -Filter "version.json" | Select-Object -First 1
if ($nested) { $src = Split-Path -Parent $nested.FullName }
}
$newVerFile = Join-Path $src "version.json"
if (-not (Test-Path $newVerFile)) {
throw "version.json missing in package"
}
$newVer = (Get-Content -Raw $newVerFile | ConvertFrom-Json).version
$oldVer = Get-NetxVersion -ProgramRoot $prog
Write-Host "==> Updating $oldVer -> $newVer"
if (-not $SkipBackup) {
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$bak = Join-Path $data "backups\pre-update-$stamp"
New-Item -ItemType Directory -Path $bak -Force | Out-Null
$envFile = Join-Path $data ".env"
if (Test-Path $envFile) {
Copy-Item $envFile (Join-Path $bak ".env")
}
Write-Host "==> Backup marker: $bak (data/pgdata left in place; optional pg_dump not run)"
}
Write-Host "==> Stopping services"
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "stop_netx_app.ps1") `
-ProgramRoot $prog -DataRoot $data
# Replace program layers only — never wipe data root.
$replaceDirs = @("netx_api", "alembic", "web", "packaging", "scripts", "postgres", "packages")
foreach ($name in $replaceDirs) {
$from = Join-Path $src $name
$to = Join-Path $prog $name
if (-not (Test-Path $from)) { continue }
Write-Host "==> Replacing $name"
if (Test-Path $to) {
Remove-Item -Recurse -Force $to
}
Copy-Item -Path $from -Destination $to -Recurse -Force
}
foreach ($f in @("requirements.txt", "pyproject.toml", "alembic.ini", "version.json", "README.md", "LICENSE")) {
$from = Join-Path $src $f
if (Test-Path $from) {
Copy-Item -Path $from -Destination (Join-Path $prog $f) -Force
}
}
# Optional runtime / .venv shipped in package
if (Test-Path (Join-Path $src "runtime")) {
$rt = Join-Path $prog "runtime"
if (Test-Path $rt) { Remove-Item -Recurse -Force $rt }
Copy-Item -Path (Join-Path $src "runtime") -Destination $rt -Recurse -Force
}
if (Test-Path (Join-Path $src ".venv")) {
Write-Host "==> Replacing .venv from package"
$venvTo = Join-Path $prog ".venv"
if (Test-Path $venvTo) { Remove-Item -Recurse -Force $venvTo }
Copy-Item -Path (Join-Path $src ".venv") -Destination $venvTo -Recurse -Force
}
Write-Host "==> Update files applied" -ForegroundColor Green
if (-not $NoStart) {
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "start_netx_app.ps1") `
-ProgramRoot $prog -DataRoot $data -SkipBrowser
}
} finally {
if (Test-Path $work) {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}
}

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netx-ops"
version = "0.2.0"
version = "0.3.0"
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP"
readme = "README.md"
requires-python = ">=3.11"

75
tests/test_ui_static.py Normal file
View file

@ -0,0 +1,75 @@
"""Tests for optional SPA static hosting."""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
def test_api_only_root_when_no_dist(monkeypatch, tmp_path: Path):
monkeypatch.setenv("NETX_UI_DIST_DIR", str(tmp_path / "missing"))
# Re-import settings + app with patched env is heavy; call helpers directly.
from netx_api.ui_static import resolve_ui_dist
from netx_api import config as cfg
monkeypatch.setattr(cfg.settings, "ui_dist_dir", str(tmp_path / "missing"))
assert resolve_ui_dist() is None
def test_resolve_ui_dist_when_index_present(monkeypatch, tmp_path: Path):
dist = tmp_path / "dist"
assets = dist / "assets"
assets.mkdir(parents=True)
(dist / "index.html").write_text("<!doctype html><title>netx</title>", encoding="utf-8")
(assets / "app.js").write_text("console.log(1)", encoding="utf-8")
from netx_api import config as cfg
from netx_api.ui_static import resolve_ui_dist
monkeypatch.setattr(cfg.settings, "ui_dist_dir", str(dist))
assert resolve_ui_dist() == dist.resolve()
def test_mount_ui_serves_index(monkeypatch, tmp_path: Path):
dist = tmp_path / "dist"
assets = dist / "assets"
assets.mkdir(parents=True)
(dist / "index.html").write_text("<!doctype html><html><body>ui</body></html>", encoding="utf-8")
(assets / "x.js").write_text("ok", encoding="utf-8")
from fastapi import FastAPI
from netx_api import config as cfg
from netx_api.ui_static import mount_ui_if_present
monkeypatch.setattr(cfg.settings, "ui_dist_dir", str(dist))
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
assert mount_ui_if_present(app) is True
client = TestClient(app)
r = client.get("/")
assert r.status_code == 200
assert "ui" in r.text
r2 = client.get("/assets/x.js")
assert r2.status_code == 200
assert r2.text == "ok"
r3 = client.get("/login")
assert r3.status_code == 200
assert "ui" in r3.text
assert client.get("/health").json()["status"] == "ok"
def test_spa_paths_are_public():
from netx_api.auth_middleware import _is_public
assert _is_public("/login") is True
assert _is_public("/topology") is True
assert _is_public("/assets/app.js") is True
assert _is_public("/v1/managed-ne") is False
assert _is_public("/health") is True
assert _is_public("/v1/auth/login") is True