From 477ec4b120d1579ba9d7f00c7308f9cf796237f7 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 6 Oct 2026 22:52:03 +0800 Subject: [PATCH] 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 --- .env.example | 9 ++ .github/workflows/release-windows.yml | 48 +++++++ .gitignore | 5 + PROD_MIN_CHECKLIST.md | 1 + README.md | 6 + netx_api/auth_middleware.py | 19 ++- netx_api/config.py | 2 + netx_api/main.py | 25 ++-- netx_api/ui_static.py | 84 ++++++++++++ packaging/README.md | 97 ++++++++++++++ packaging/_common.ps1 | 137 +++++++++++++++++++ packaging/build_release.ps1 | 123 +++++++++++++++++ packaging/config/database.example.env | 9 ++ packaging/download_postgres.ps1 | 57 ++++++++ packaging/installer/netx.iss | 69 ++++++++++ packaging/manifest.example.json | 11 ++ packaging/postgres/README.md | 17 +++ packaging/publish_release.ps1 | 101 ++++++++++++++ packaging/setup_first_run.ps1 | 185 ++++++++++++++++++++++++++ packaging/start_netx_app.ps1 | 112 ++++++++++++++++ packaging/stop_netx_app.ps1 | 46 +++++++ packaging/update_netx.ps1 | 104 +++++++++++++++ pyproject.toml | 2 +- tests/test_ui_static.py | 75 +++++++++++ 24 files changed, 1332 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/release-windows.yml create mode 100644 netx_api/ui_static.py create mode 100644 packaging/README.md create mode 100644 packaging/_common.ps1 create mode 100644 packaging/build_release.ps1 create mode 100644 packaging/config/database.example.env create mode 100644 packaging/download_postgres.ps1 create mode 100644 packaging/installer/netx.iss create mode 100644 packaging/manifest.example.json create mode 100644 packaging/postgres/README.md create mode 100644 packaging/publish_release.ps1 create mode 100644 packaging/setup_first_run.ps1 create mode 100644 packaging/start_netx_app.ps1 create mode 100644 packaging/stop_netx_app.ps1 create mode 100644 packaging/update_netx.ps1 create mode 100644 tests/test_ui_static.py diff --git a/.env.example b/.env.example index 8d28312..7ede38d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 0000000..12e2fa9 --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -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 diff --git a/.gitignore b/.gitignore index 80fd701..92197b4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/PROD_MIN_CHECKLIST.md b/PROD_MIN_CHECKLIST.md index f0d6f64..ff8e580 100644 --- a/PROD_MIN_CHECKLIST.md +++ b/PROD_MIN_CHECKLIST.md @@ -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. diff --git a/README.md b/README.md index 2e27e94..2ce7550 100644 --- a/README.md +++ b/README.md @@ -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 宿主同机** 安装轻量客户端并配置。 diff --git a/netx_api/auth_middleware.py b/netx_api/auth_middleware.py index d6c9bd3..3375420 100644 --- a/netx_api/auth_middleware.py +++ b/netx_api/auth_middleware.py @@ -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: diff --git a/netx_api/config.py b/netx_api/config.py index 0fb1a12..1126fc5 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -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" diff --git a/netx_api/main.py b/netx_api/main.py index 98eb06f..e56f91d 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -95,16 +95,21 @@ def health() -> dict[str, str]: return {"status": "ok"} -@app.get("/") -def root() -> dict: - return { - "ok": True, - "mode": "api_only", - "message": "netx UI is served by Vite frontend only", - "frontend_url": settings.frontend_url, - "api_health": "/health", - "api_status": "/v1/integrations/status", - } +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", + "message": "netx UI is served by Vite frontend only", + "frontend_url": settings.frontend_url, + "api_health": "/health", + "api_status": "/v1/integrations/status", + } if __name__ == "__main__": diff --git a/netx_api/ui_static.py b/netx_api/ui_static.py new file mode 100644 index 0000000..e606adf --- /dev/null +++ b/netx_api/ui_static.py @@ -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 diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..4f22d6f --- /dev/null +++ b/packaging/README.md @@ -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--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\ +``` diff --git a/packaging/_common.ps1 b/packaging/_common.ps1 new file mode 100644 index 0000000..4a1ef66 --- /dev/null +++ b/packaging/_common.ps1 @@ -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 \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 +} diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 new file mode 100644 index 0000000..15f2e2b --- /dev/null +++ b/packaging/build_release.ps1 @@ -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" diff --git a/packaging/config/database.example.env b/packaging/config/database.example.env new file mode 100644 index 0000000..a9d072a --- /dev/null +++ b/packaging/config/database.example.env @@ -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 diff --git a/packaging/download_postgres.ps1 b/packaging/download_postgres.ps1 new file mode 100644 index 0000000..38c7b41 --- /dev/null +++ b/packaging/download_postgres.ps1 @@ -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." diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss new file mode 100644 index 0000000..55090ea --- /dev/null +++ b/packaging/installer/netx.iss @@ -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}" diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json new file mode 100644 index 0000000..9f51c14 --- /dev/null +++ b/packaging/manifest.example.json @@ -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 + } +} diff --git a/packaging/postgres/README.md b/packaging/postgres/README.md new file mode 100644 index 0000000..fcda877 --- /dev/null +++ b/packaging/postgres/README.md @@ -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. diff --git a/packaging/publish_release.ps1 b/packaging/publish_release.ps1 new file mode 100644 index 0000000..193c36c --- /dev/null +++ b/packaging/publish_release.ps1 @@ -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 diff --git a/packaging/setup_first_run.ps1 b/packaging/setup_first_run.ps1 new file mode 100644 index 0000000..fa8e0f5 --- /dev/null +++ b/packaging/setup_first_run.ps1 @@ -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" diff --git a/packaging/start_netx_app.ps1 b/packaging/start_netx_app.ps1 new file mode 100644 index 0000000..3fe2313 --- /dev/null +++ b/packaging/start_netx_app.ps1 @@ -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 {} +} diff --git a/packaging/stop_netx_app.ps1 b/packaging/stop_netx_app.ps1 new file mode 100644 index 0000000..174c631 --- /dev/null +++ b/packaging/stop_netx_app.ps1 @@ -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" diff --git a/packaging/update_netx.ps1 b/packaging/update_netx.ps1 new file mode 100644 index 0000000..1ddd0ec --- /dev/null +++ b/packaging/update_netx.ps1 @@ -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 + } +} diff --git a/pyproject.toml b/pyproject.toml index fde81d5..a2836f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_ui_static.py b/tests/test_ui_static.py new file mode 100644 index 0000000..2e1c9f4 --- /dev/null +++ b/tests/test_ui_static.py @@ -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("netx", 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("ui", 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