diff --git a/packaging/README.md b/packaging/README.md index ec06370..ec2353b 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -56,15 +56,17 @@ Optional local zip only: `build_release.ps1 -CreateZip` (not for release upload) 1. Run `NetX-Setup-x.y.z.exe` (admin). 2. **First install:** Database page — choose built-in (offline) or external PostgreSQL (host/port/user/password/db; connection must succeed to continue). -3. **Already installed (upgrade):** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup **skips** the Database page, stops running NetX, replaces program files, and **keeps** data / DB settings. Use Start Menu → **Reconfigure database** only when you need to change DB mode. +3. **Already installed:** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup shows an **Install mode** page: + - **Update** (default) — skip Database page; stop NetX; replace program files; **keep** ProgramData / DB settings. + - **Reinstall** — same Database wizard as first install (`ApplyDatabaseConfig`); does **not** delete ProgramData (use Uninstall → delete data for a wipe). 4. Files → `%ProgramFiles%\NetX\` (program root). 5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets). 6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**. Silent first install (bundled default): `/SILENT /DbMode=bundled` Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...` -Silent upgrade over existing data: `/VERYSILENT /NORESTART` (auto-detects `.env`; or force `/SkipDbPage=1`) -Force DB wizard on an existing install: `/ForceDbPage=1` +Silent update over existing data: `/VERYSILENT /NORESTART /InstallMode=update` (also `/SkipDbPage=1`) +Silent reinstall (DB wizard via params): `/VERYSILENT /InstallMode=reinstall /DbMode=bundled` (or external `/DbHost`…) — also `/ForceDbPage=1` Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate. ## Database modes @@ -79,7 +81,7 @@ Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never s ## Manual update -Preferred: run a newer `NetX-Setup-*.exe` over the same machine (upgrade mode keeps ProgramData). +Preferred: run a newer `NetX-Setup-*.exe` and choose **Update** (or silent `/InstallMode=update`) so ProgramData is kept. Setup (elevated) always relinks `.venv` → `python/runtime` after file copy so tray start works without write access under Program Files. Legacy/dev zip (only if you built with `-CreateZip`): diff --git a/packaging/_common.ps1 b/packaging/_common.ps1 index 6c2c9dd..0a22f55 100644 --- a/packaging/_common.ps1 +++ b/packaging/_common.ps1 @@ -404,8 +404,23 @@ function Repair-NetxShippedVenv { "version = $ver", "executable = $rtPyAbs" ) - Set-Content -LiteralPath $cfgPath -Value ($lines -join "`r`n") -Encoding ascii - return $true + $desired = ($lines -join "`r`n") + # Skip write when already correct — Users cannot modify Program Files after Setup. + try { + $cur = ([IO.File]::ReadAllText($cfgPath) -replace "`r`n", "`n" -replace "`r", "`n").Trim() + $want = ($desired -replace "`r`n", "`n" -replace "`r", "`n").Trim() + if ($cur -eq $want) { + return $true + } + } catch {} + + try { + Set-Content -LiteralPath $cfgPath -Value $desired -Encoding ascii -ErrorAction Stop + return $true + } catch { + # Non-elevated tray/start: leave cfg as-is; caller may still run if paths happen to work. + return $false + } } function Test-NetxVenvRunnable { @@ -446,7 +461,11 @@ function Ensure-NetxVenv { $rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe" if (Test-Path -LiteralPath $rtPy) { - $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot + try { + $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot + } catch { + # Access denied under Program Files when not elevated — ignore if venv already runs. + } } if ((Test-Path -LiteralPath $venvPy) -and -not $ForcePip) { @@ -455,11 +474,19 @@ function Ensure-NetxVenv { } Write-Host "[WARN] Shipped .venv exists but Python cannot start (broken pyvenv.cfg or missing runtime)." -ForegroundColor Yellow if (Test-Path -LiteralPath $rtPy) { - $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot - if (Test-NetxVenvRunnable -VenvPython $venvPy) { + $repaired = $false + try { + $repaired = [bool](Repair-NetxShippedVenv -ProgramRoot $ProgramRoot) + } catch { + $repaired = $false + } + if ($repaired -and (Test-NetxVenvRunnable -VenvPython $venvPy)) { Write-Host "==> Repaired .venv to use bundled python/runtime" -ForegroundColor Green return $venvPy } + if (-not $repaired) { + throw "venv_needs_admin_repair: pyvenv.cfg still points at the build PC. Re-run Setup (Update) as Administrator, or Start Menu → Reconfigure database once elevated." + } } if (-not $ForcePip) { throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin" diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 index 7e3fd46..0d45bf9 100644 --- a/packaging/check_update.ps1 +++ b/packaging/check_update.ps1 @@ -404,15 +404,22 @@ try { $env:NETX_UPDATE_ELEVATED = "1" if ($isSetup) { - # Silent Setup over an existing configured install skips the DB wizard - # (see installer/netx.iss GSkipDbPage) and preserves ProgramData. + # Silent Setup update mode: skip DB wizard, keep ProgramData + # (see installer/netx.iss /InstallMode=update). Write-Host "==> Applying update via silent Setup.exe" - $setupArgs = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /DIR=`"$prog`" /SkipDbPage=1" + $setupArgs = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /DIR=`"$prog`" /InstallMode=update" $sp = Start-Process -FilePath $pkgPath -ArgumentList $setupArgs -Wait -PassThru if ($null -eq $sp.ExitCode -or $sp.ExitCode -ne 0) { $code = if ($null -eq $sp.ExitCode) { "null" } else { $sp.ExitCode } throw "setup_update_failed: exit $code" } + # Setup post-install also repairs; belt-and-suspenders when running elevated apply. + $repairPs1 = Join-Path $prog "packaging\repair_venv.ps1" + if (Test-Path -LiteralPath $repairPs1) { + Write-Host "==> Relinking .venv to bundled python/runtime" + & powershell -NoProfile -ExecutionPolicy Bypass -File $repairPs1 ` + -ProgramRoot $prog -DataRoot $data + } Write-Host "==> Restarting NetX after Setup" & (Join-Path $PSScriptRoot "start_netx_app.ps1") ` -ProgramRoot $prog -DataRoot $data -SkipBrowser diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index 7e4a20e..42b836e 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -5,7 +5,7 @@ #define MyAppName "NetX" #ifndef MyAppVersion - #define MyAppVersion "0.4.11" + #define MyAppVersion "0.4.12" #endif #define MyAppPublisher "NetX" #define MyAppURL "https://github.com/hansjone/netx" @@ -78,6 +78,12 @@ english.DbBundledMissing=Built-in PostgreSQL files are missing from the install english.DbEnvMissing=Database configuration did not write %ProgramData%\NetX\.env. english.ReconfigureDb=Reconfigure database english.UpgradeKeepDb=Existing NetX data found - database settings will be kept (no wizard). +english.ModePageCaption=Install mode +english.ModePageDescription=An existing NetX installation was found. Choose update or reinstall. +english.ModeUpdate=Update existing installation (keep database settings) +english.ModeReinstall=Reinstall and reconfigure database (same as first install) +english.ModeUpdateHint=Replaces program files only. ProgramData\.env and database data are kept. +english.ModeReinstallHint=Shows the Database page again. Does not delete ProgramData unless you uninstall and choose to. chinesesimplified.CreateDesktopIcon=创建桌面快捷方式 chinesesimplified.SetupOptions=安装完成后: chinesesimplified.StartTrayNow=立即启动 NetX 托盘 @@ -107,6 +113,12 @@ chinesesimplified.DbBundledMissing=安装目录中缺少内置 PostgreSQL 文件 chinesesimplified.DbEnvMissing=数据库配置未写入 %ProgramData%\NetX\.env。 chinesesimplified.ReconfigureDb=重新配置数据库 chinesesimplified.UpgradeKeepDb=检测到已有 NetX 数据 - 将保留数据库配置(跳过向导)。 +chinesesimplified.ModePageCaption=安装模式 +chinesesimplified.ModePageDescription=检测到已有 NetX 安装。请选择更新或重装。 +chinesesimplified.ModeUpdate=更新现有安装(保留数据库配置) +chinesesimplified.ModeReinstall=重装并重新配置数据库(与首次安装相同) +chinesesimplified.ModeUpdateHint=仅替换程序文件,保留 ProgramData\.env 与数据库数据。 +chinesesimplified.ModeReinstallHint=再次显示数据库页。不会删除 ProgramData(除非卸载时选择删除)。 [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce @@ -203,6 +215,11 @@ var GCredKey: String; GSkipDbPage: Boolean; GIsUpgrade: Boolean; + GInstallMode: String; + ModePage: TWizardPage; + RbModeUpdate: TNewRadioButton; + RbModeReinstall: TNewRadioButton; + LblModeHint: TNewStaticText; GDbConfigured: Boolean; GPgToolsReady: Boolean; GDbConnOk: Boolean; @@ -265,6 +282,64 @@ begin Result := EnvHasDbMode(); end; +procedure SyncSkipDbFromInstallMode(); +begin + GSkipDbPage := (GInstallMode = 'update'); +end; + +procedure ApplyInstallModeFromChoice(); +begin + if (RbModeReinstall <> nil) and RbModeReinstall.Checked then + GInstallMode := 'reinstall' + else if GIsUpgrade then + GInstallMode := 'update' + else + GInstallMode := 'fresh'; + SyncSkipDbFromInstallMode(); +end; + +procedure ModeChoiceClick(Sender: TObject); +begin + ApplyInstallModeFromChoice(); + if LblModeHint = nil then + Exit; + if GInstallMode = 'reinstall' then + LblModeHint.Caption := ExpandConstant('{cm:ModeReinstallHint}') + else + LblModeHint.Caption := ExpandConstant('{cm:ModeUpdateHint}'); +end; + +function ResolveInstallModeFromParams(): Boolean; +{ Returns True if a CLI param forced the mode. } +var + Mode: String; +begin + Result := False; + Mode := LowerCase(Trim(ExpandConstant('{param:InstallMode|}'))); + if (Mode = 'update') or (Mode = 'upgrade') then + begin + GInstallMode := 'update'; + Result := True; + end + else if (Mode = 'reinstall') or (Mode = 'fresh') then + begin + GInstallMode := 'reinstall'; + Result := True; + end + else if ParamIsTruthy('SkipDbPage') then + begin + GInstallMode := 'update'; + Result := True; + end + else if ParamIsTruthy('ForceDbPage') then + begin + GInstallMode := 'reinstall'; + Result := True; + end; + if Result then + SyncSkipDbFromInstallMode(); +end; + procedure StopNetxBeforeFileReplace(); var ResultCode: Integer; @@ -538,18 +613,62 @@ begin GDbConfigured := False; GPgToolsReady := False; GDbConnOk := False; - { Upgrade / reinstall over a configured install: skip DB wizard and keep - ProgramData\.env / pgdata. Fresh install still shows the Database page. - Force wizard: /ForceDbPage=1 Force skip: /SkipDbPage=1 } + { Existing install: interactive Mode page (Update vs Reinstall). + Silent: /InstallMode=update|reinstall (or /SkipDbPage=1 /ForceDbPage=1). } GIsUpgrade := DetectExistingConfiguredInstall(); - if ParamIsTruthy('ForceDbPage') then - GSkipDbPage := False - else if ParamIsTruthy('SkipDbPage') then - GSkipDbPage := True - else - GSkipDbPage := GIsUpgrade; + ModePage := nil; + RbModeUpdate := nil; + RbModeReinstall := nil; + LblModeHint := nil; + if not ResolveInstallModeFromParams() then + begin + if GIsUpgrade then + GInstallMode := 'update' + else + GInstallMode := 'fresh'; + SyncSkipDbFromInstallMode(); + end; - DbPage := CreateCustomPage(wpSelectDir, + if GIsUpgrade and (not WizardSilent) then + begin + ModePage := CreateCustomPage(wpSelectDir, + ExpandConstant('{cm:ModePageCaption}'), + ExpandConstant('{cm:ModePageDescription}')); + + RbModeUpdate := TNewRadioButton.Create(ModePage); + RbModeUpdate.Parent := ModePage.Surface; + RbModeUpdate.Caption := ExpandConstant('{cm:ModeUpdate}'); + RbModeUpdate.Checked := (GInstallMode <> 'reinstall'); + RbModeUpdate.Top := ScaleY(8); + RbModeUpdate.Left := ScaleX(0); + RbModeUpdate.Width := ModePage.SurfaceWidth; + RbModeUpdate.OnClick := @ModeChoiceClick; + + RbModeReinstall := TNewRadioButton.Create(ModePage); + RbModeReinstall.Parent := ModePage.Surface; + RbModeReinstall.Caption := ExpandConstant('{cm:ModeReinstall}'); + RbModeReinstall.Checked := (GInstallMode = 'reinstall'); + RbModeReinstall.Top := RbModeUpdate.Top + ScaleY(28); + RbModeReinstall.Left := ScaleX(0); + RbModeReinstall.Width := ModePage.SurfaceWidth; + RbModeReinstall.OnClick := @ModeChoiceClick; + + LblModeHint := TNewStaticText.Create(ModePage); + LblModeHint.Parent := ModePage.Surface; + LblModeHint.Top := RbModeReinstall.Top + ScaleY(36); + LblModeHint.Left := ScaleX(0); + LblModeHint.Width := ModePage.SurfaceWidth; + LblModeHint.Height := ScaleY(60); + LblModeHint.AutoSize := False; + LblModeHint.WordWrap := True; + ModeChoiceClick(nil); + + DbPage := CreateCustomPage(ModePage.ID, + ExpandConstant('{cm:DbPageCaption}'), + ExpandConstant('{cm:DbPageDescription}')); + end + else + DbPage := CreateCustomPage(wpSelectDir, ExpandConstant('{cm:DbPageCaption}'), ExpandConstant('{cm:DbPageDescription}')); @@ -687,6 +806,12 @@ end; function ShouldSkipPage(PageID: Integer): Boolean; begin Result := False; + if (ModePage <> nil) and (PageID = ModePage.ID) then + begin + { Only interactive existing installs show the mode page. } + Result := (not GIsUpgrade) or WizardSilent; + Exit; + end; if (DbPage <> nil) and (PageID = DbPage.ID) then Result := GSkipDbPage; end; @@ -696,6 +821,11 @@ var ErrMsg: String; begin Result := True; + if (ModePage <> nil) and (CurPageID = ModePage.ID) then + begin + ApplyInstallModeFromChoice(); + Exit; + end; { Only the DB page runs validation. Other pages must always proceed (Tasks/Ready used to appear "dead" when CloseApplications hung). } if (DbPage = nil) or (CurPageID <> DbPage.ID) or GSkipDbPage then @@ -736,6 +866,14 @@ var begin Result := ''; NeedsRestart := False; + + if (ModePage <> nil) and (not WizardSilent) then + ApplyInstallModeFromChoice(); + + { Stop tray / API / bundled PG so Program Files can be replaced safely. } + if GSkipDbPage or GIsUpgrade or DirExists(ExpandConstant('{app}\packaging')) then + StopNetxBeforeFileReplace(); + if GSkipDbPage then Exit; @@ -897,12 +1035,31 @@ begin end; end; +procedure RepairNetxVenvAfterInstall(); +var + ResultCode: Integer; + RepairScript: String; + Params: String; +begin + RepairScript := ExpandConstant('{app}\packaging\repair_venv.ps1'); + if not FileExists(RepairScript) then + Exit; + Params := + '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + RepairScript + '"' + + ' -ProgramRoot "' + ExpandConstant('{app}') + '"' + + ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"'; + Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + procedure CurStepChanged(CurStep: TSetupStep); var UninstallKey: String; begin if CurStep = ssPostInstall then begin + { Always relink .venv -> local python/runtime (Setup is elevated). } + RepairNetxVenvAfterInstall(); + { Upgrade keeps existing .env / pgdata - do not re-run DB wizard. } if not GSkipDbPage then ApplyDatabaseConfig() diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json index 36ac2c8..410b5ee 100644 --- a/packaging/manifest.example.json +++ b/packaging/manifest.example.json @@ -1,11 +1,11 @@ { "channel": "stable", - "latest": "0.4.11", + "latest": "0.4.12", "min_compatible": "0.3.0", - "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.11", + "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.12", "windows": { - "url": "https://github.com/hansjone/netx/releases/download/v0.4.11/NetX-Setup-0.4.11.exe", - "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.11/NetX-Setup-0.4.11.exe", + "url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe", + "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe", "package": "setup", "sha256": "", "size": 0 diff --git a/packaging/repair_venv.ps1 b/packaging/repair_venv.ps1 new file mode 100644 index 0000000..4172d3a --- /dev/null +++ b/packaging/repair_venv.ps1 @@ -0,0 +1,25 @@ +param( + [string]$ProgramRoot = "", + [string]$DataRoot = "" +) + +# Relink shipped .venv to local python/runtime (run elevated after Setup/update). +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\_common.ps1" + +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot + +Write-Host "==> Repairing .venv under $prog" +if (Repair-NetxShippedVenv -ProgramRoot $prog) { + $venvPy = Join-Path $prog ".venv\Scripts\python.exe" + if (Test-NetxVenvRunnable -VenvPython $venvPy) { + Write-Host "==> .venv OK -> bundled python/runtime" -ForegroundColor Green + Get-Content (Join-Path $prog ".venv\pyvenv.cfg") + exit 0 + } + Write-Host "[ERR] pyvenv.cfg written but venv still not runnable" -ForegroundColor Red + exit 2 +} +Write-Host "[ERR] repair failed (missing runtime/.venv or access denied)" -ForegroundColor Red +exit 1 diff --git a/pyproject.toml b/pyproject.toml index 4278ff3..9263218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netx-ops" -version = "0.4.11" +version = "0.4.12" description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" readme = "README.md" requires-python = ">=3.11"