#Requires -Version 5.1 <# .SYNOPSIS NexusOS installer for Windows (native). .DESCRIPTION Installs Python, Node.js, and Ollama via winget, builds the Promethean virtualenv and the web UI, and drops a desktop shortcut. NexusOS then runs as a single process: the backend on :8000 serves the built UI, and the AI (Ollama) is started manually from the UI. Run from the nexus-core directory: Right-click install-windows.ps1 -> "Run with PowerShell" Requires Windows 10/11 with winget (App Installer). No reboot needed. .PARAMETER OwnsWindow Internal. Set only on the elevated run this script starts for itself. That run has a console of its own, so a fatal error has to pause before the window disappears with the message on it. In a shell the user already had open the text stays on screen, and a prompt there is a keystroke nobody asked for. Not meant to be passed by hand. #> param( [switch]$OwnsWindow ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $RepoRoot = $PSScriptRoot # winget draws its progress bar with U+2588/U+2592 block characters in UTF-8, # but PowerShell decodes a native command's output using the CONSOLE codepage # (437 on a US box). The three UTF-8 bytes of a block then render as "Gamma u e" # - the log fills with 'GubebGubeb...' mojibake and the real messages get lost # in it. Decoding as UTF-8 is the fix; it is per-process and does not touch the # user's console settings. [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # -- Helpers ------------------------------------------------------------------- function Write-Step { param([string]$Msg) Write-Host "`n==> $Msg" -ForegroundColor Cyan } function Write-OK { param([string]$Msg) Write-Host " ok: $Msg" -ForegroundColor Green } function Write-Warn { param([string]$Msg) Write-Host " warn: $Msg" -ForegroundColor Yellow } function Write-Fail { param([string]$Msg) Write-Host "`n ERROR: $Msg`n" -ForegroundColor Red # Only hold the window open if closing it would take the error message with # it. In someone else's shell the text stays on screen regardless, and a # prompt there is just a keystroke they did not ask for. if ($OwnsWindow) { Read-Host "Press Enter to exit" } exit 1 } # Pull the current machine + user PATH out of the registry into this session, so # tools winget just installed become runnable without opening a new shell. function Update-SessionPath { $m = [System.Environment]::GetEnvironmentVariable("Path", "Machine") $u = [System.Environment]::GetEnvironmentVariable("Path", "User") $env:Path = ($m, $u | Where-Object { $_ }) -join ";" } function Install-Winget { param([string]$Id, [string]$Label) Write-Step "Installing $Label" # winget animates a spinner ("-\|/") and a block progress bar using carriage # returns to redraw one line in place. Piping it defeats that: PowerShell # splits on the CRs, so every frame arrives as its own line and the log fills # with dozens of lone dashes and bar snapshots. Drop the frames and keep the # sentences. The block characters are spelled by code point because every # .ps1 here must stay ASCII (tests/test_smoke.py enforces it). $bar = "$([char]0x2588)$([char]0x2592)" winget install --id $Id -e --source winget ` --accept-package-agreements --accept-source-agreements --disable-interactivity | Where-Object { $_ -notmatch "[$bar]" -and $_ -notmatch '^\s*[-\\|/]\s*$' -and $_.Trim() } | Out-Host # winget returns non-zero when the package is already installed / up to date; # that's not a failure for us. Verify presence after refreshing PATH instead. Update-SessionPath Write-OK "$Label step done" } # -- Self-elevate to Administrator --------------------------------------------- $IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $IsAdmin) { Write-Host "Requesting administrator privileges..." -ForegroundColor Yellow # -Wait -PassThru so this window blocks until the elevated install finishes # and can report success/failure itself - without it, this window just says # "Requesting..." and exits immediately, while all real progress (and the # "installed!" banner) happens in the separate elevated window, making the # original window look like it silently quit. $proc = Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -OwnsWindow" -Verb RunAs -Wait -PassThru Write-Host "" if ($proc.ExitCode -eq 0) { Write-Host " NexusOS installed successfully." -ForegroundColor Green Write-Host " Open a NEW terminal and run 'ncp help', or double-click the NexusOS desktop shortcut." -ForegroundColor White } else { Write-Host " Install did not finish (exit code $($proc.ExitCode)). Check the elevated window for errors." -ForegroundColor Red } exit $proc.ExitCode } Write-Host "" Write-Host " NexusOS Installer for Windows (native)" -ForegroundColor White Write-Host " ======================================" -ForegroundColor DarkGray # -- winget present? ----------------------------------------------------------- Write-Step "Checking winget" if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Write-Fail "winget not found. Install 'App Installer' from the Microsoft Store, then re-run this installer." } Write-OK "winget available" # -- Install runtimes ---------------------------------------------------------- Install-Winget "Python.Python.3.12" "Python 3.12" Install-Winget "OpenJS.NodeJS.LTS" "Node.js LTS" Install-Winget "Ollama.Ollama" "Ollama" # -- Verify tools -------------------------------------------------------------- Write-Step "Verifying tools" foreach ($t in @("python", "npm", "ollama")) { if (-not (Get-Command $t -ErrorAction SilentlyContinue)) { Write-Fail "$t is not on PATH after install. Close this window, open a new PowerShell, and re-run the installer." } Write-OK "$t found" } # -- Python venv + deps -------------------------------------------------------- Write-Step "Building the Promethean virtualenv" $Venv = Join-Path $RepoRoot "Promethean" $VenvPy = Join-Path $Venv "Scripts\python.exe" if (-not (Test-Path $VenvPy)) { python -m venv $Venv } if (-not (Test-Path $VenvPy)) { Write-Fail "venv creation failed at $Venv" } & $VenvPy -m pip install --upgrade pip -q $Req = Join-Path $RepoRoot "requirements-windows.txt" # CPU / pure-Python overlay - right for native Windows too if (-not (Test-Path $Req)) { Write-Fail "requirements file not found: $Req" } & $VenvPy -m pip install -r $Req if ($LASTEXITCODE -ne 0) { Write-Fail "pip install failed (exit $LASTEXITCODE) - see output above" } Write-OK "Python environment ready" # -- Frontend build ------------------------------------------------------------ Write-Step "Building the web UI" Push-Location (Join-Path $RepoRoot "interface\web") npm install if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail "npm install failed (exit $LASTEXITCODE) - see output above" } npm run build if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail "npm run build failed (exit $LASTEXITCODE) - no UI would be served" } Pop-Location if (-not (Test-Path (Join-Path $RepoRoot "interface\web\dist\index.html"))) { Write-Fail "build reported success but interface\web\dist\index.html is missing" } Write-OK "Web UI built (interface\web\dist)" # -- ncp command --------------------------------------------------------------- # management\ncp.cmd goes on the machine PATH rather than a `function ncp` in the # PowerShell profile. Three reasons the profile route kept biting: # 1. Execution policy defaults to Restricted, which blocks the profile itself - # so the function was never defined, and the .ps1 it called could not # have run anyway. # 2. Profiles are a PowerShell thing. cmd, Win+R, Task Scheduler and .lnk # targets all reported "ncp is not recognized". # 3. We are elevated here, so $PROFILE is the ADMIN's profile - the whole # -InvokerProfile dance existed to work around that. Machine PATH has no # such split. # The Linux side does the matching thing: a symlink in /usr/local/bin. Write-Step "Registering the ncp command" try { $NcpCmd = Join-Path $RepoRoot "management\ncp.cmd" if (-not (Test-Path $NcpCmd)) { throw "ncp.cmd not found at '$NcpCmd'" } $BinDir = Split-Path -Parent $NcpCmd # Machine scope needs admin, which the self-elevation above already gave us. # Read-modify-write through .NET, NEVER setx: setx silently truncates PATH at # 1024 characters and has permanently broken machines. $Current = [Environment]::GetEnvironmentVariable("Path", "Machine") $Entries = @($Current -split ';' | Where-Object { $_ -ne "" }) if ($Entries -contains $BinDir) { Write-OK "ncp already on the machine PATH ($BinDir)" } else { $New = (@($Entries) + $BinDir) -join ';' [Environment]::SetEnvironmentVariable("Path", $New, "Machine") Write-OK "ncp registered on the machine PATH ($BinDir)" } # PATH is read at process start, so this shell and any already-open one still # do not have it. Patch ours so the rest of the installer can call ncp. $env:Path = "$env:Path;$BinDir" } catch { Write-Warn "Could not put ncp on PATH - add this directory to PATH by hand:" Write-Warn " $RepoRoot\management" } # -- Legacy profile shim check -------------------------------------------------- # An earlier version of this installer registered ncp as a `function ncp` in the # PowerShell profile instead of management\ncp.cmd (see the "why" above). Profile # functions resolve before PATH executables, so a leftover one silently shadows # the real ncp.cmd - `ncp` still runs, just the wrong code, with no error. We do # not rewrite the user's profile automatically: it is their file, and a blind # strip risks mangling whatever else lives in it alongside it. Just flag it. Write-Step "Checking for a stale ncp() in your PowerShell profile" $DocsRoot = [Environment]::GetFolderPath("MyDocuments") $ProfileCandidates = @( (Join-Path $DocsRoot "WindowsPowerShell\Microsoft.PowerShell_profile.ps1"), (Join-Path $DocsRoot "PowerShell\Microsoft.PowerShell_profile.ps1") ) $Shadowed = $ProfileCandidates | Where-Object { (Test-Path $_) -and (Select-String -Path $_ -Pattern '^\s*function\s+ncp\b' -Quiet) } if ($Shadowed) { Write-Warn "Found an old 'function ncp' that will shadow the real ncp.cmd:" foreach ($p in $Shadowed) { Write-Warn " $p" } Write-Warn " Remove that function from the file(s) above so ncp resolves to ncp.cmd." } else { Write-OK "No stale ncp() in your PowerShell profile" } # -- Desktop shortcut ---------------------------------------------------------- Write-Step "Creating desktop shortcut" try { # Target the .vbs wrapper, not powershell.exe directly: on Windows 11 with # Windows Terminal as the default terminal app, -WindowStyle Hidden on a # console-subsystem process is often ignored and a terminal tab flashes up # anyway. wscript.exe never allocates a console at all, sidestepping that. $Launcher = Join-Path $RepoRoot "bin\launch_nexus_hidden.vbs" $LnkPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "NexusOS.lnk" $ws = New-Object -ComObject WScript.Shell $lnk = $ws.CreateShortcut($LnkPath) $lnk.TargetPath = "$env:WINDIR\System32\wscript.exe" $lnk.Arguments = "`"$Launcher`"" $lnk.WorkingDirectory = $RepoRoot $lnk.Description = "Launch NexusOS" $Ico = Join-Path $RepoRoot "assets\NexusOS.ico" if (Test-Path $Ico) { $lnk.IconLocation = "$Ico,0" } $lnk.Save() Write-OK "Desktop shortcut created" } catch { Write-Warn "Could not create desktop shortcut - launch with: powershell -File launch_nexus.ps1" } # -- Prune Linux-only assets ----------------------------------------------------- # assets\ ships the Linux desktop theme (GTK/KDE/XFWM4, boot splash, XFCE panel # icons) alongside NexusOS.ico, the one file Windows actually uses (the shortcut # icon, just set above). None of the rest does anything without a GTK/KDE/XFWM4/ # Plymouth desktop under it to theme - dead weight in a Windows checkout, not a # future-proofing keep. Write-Step "Removing Linux-only theme assets" try { $AssetsDir = Join-Path $RepoRoot "assets" $IcoPath = Join-Path $AssetsDir "NexusOS.ico" $Freed = (Get-ChildItem $AssetsDir -Recurse -Force -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -ne $IcoPath } | Measure-Object -Property Length -Sum).Sum Get-ChildItem $AssetsDir -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "NexusOS.ico" } | Remove-Item -Recurse -Force if ($Freed) { Write-OK "Removed $([math]::Round($Freed / 1MB, 1)) MB (Linux desktop theme - not used on Windows)" } else { Write-OK "Nothing to remove" } } catch { Write-Warn "Could not prune assets\ - harmless, just extra disk space" } # -- Pull models (best-effort, LAST on purpose) -------------------------------- # This is the only multi-GB step, and the only one a user is invited to Ctrl+C. # Ctrl+C in PowerShell 5.1 terminates the whole SCRIPT, not just the running # native command - so with this block in the middle, skipping the download also # skipped the PATH registration and the desktop shortcut, and left an install # that looked finished but had no working `ncp`. Everything that makes NexusOS # usable now runs before we get here; abort at this point and you lose only the # models, which the Models tab can pull later. # # Which models ship is decided in ONE place - DEFAULT_CHAT_MODEL and # DEFAULT_MEMORY_MODEL in synapse\nexus_config.py (rationale documented there). # Read them instead of hardcoding, so the installer can never pull one model # while the backend defaults to another. Push-Location $RepoRoot $ChatModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_CHAT_MODEL as m; print(m)") $MemModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_MEMORY_MODEL as m; print(m)") $EmbedModel = (& $VenvPy -c "from synapse.nexus_config import DEFAULT_EMBED_MODEL as m; print(m)") Pop-Location if ($LASTEXITCODE -ne 0 -or -not $ChatModel -or -not $MemModel -or -not $EmbedModel) { Write-Fail "Could not read the default models from synapse\nexus_config.py - the venv install is broken" } # Models live in the project's own store on both platforms, so `ncp models list` # and a NexusOS-spawned `ollama serve` agree on where they are. Without this the # installer pulled into %USERPROFILE%\.ollama and NexusOS looked somewhere else. Write-Step "Pointing Ollama at the project model store" $ModelStore = Join-Path $RepoRoot "models" New-Item -ItemType Directory -Path $ModelStore -Force | Out-Null # An existing install has GBs already downloaded in the default store. Move it # rather than make the user fetch it again; on the same volume this is instant. $LegacyStore = Join-Path $env:USERPROFILE ".ollama\models" if ((Test-Path (Join-Path $LegacyStore "blobs")) -and -not (Test-Path (Join-Path $ModelStore "blobs"))) { Write-Host " Moving models from $LegacyStore (no re-download)..." -ForegroundColor DarkGray try { foreach ($sub in @("blobs", "manifests")) { $src = Join-Path $LegacyStore $sub if (Test-Path $src) { Move-Item -Path $src -Destination (Join-Path $ModelStore $sub) -Force } } Write-OK "Existing models moved into $ModelStore" } catch { Write-Warn "Could not move the old model store - the pull below will refetch." } } $env:OLLAMA_MODELS = $ModelStore Write-OK "Model store: $ModelStore" # Pin it as the default chat model. Runs BEFORE the download because it only # writes a settings row - nothing here needs the model to be present on disk, # and putting it after the pull meant declining the download also lost it. Runs from the repo root so the synapse # package imports; only writes the 'model' setting in the shared DB. Write-Step "Setting $ChatModel as the default model" Push-Location $RepoRoot & $VenvPy -c "from synapse.memory.store import store; from synapse.nexus_config import DEFAULT_CHAT_MODEL; store.update_settings({'model': DEFAULT_CHAT_MODEL})" $seedOk = ($LASTEXITCODE -eq 0) Pop-Location if ($seedOk) { Write-OK "Default model set to $ChatModel" } else { Write-Warn "Could not persist default model - pick it at the top of the chat instead." } # No auto-download: the right models depend on the machine (a 4GB GPU can't fit # an 8B model). The Models tab detects VRAM/RAM and marks which models fit, so # the user pulls the right ones there instead of us guessing several GB. Write-Step "Skipping model download (pick hardware-appropriate models in the app)" Write-Host " No models were downloaded. Open NexusOS -> Models. The Required tab" -ForegroundColor DarkGray Write-Host " lists the two models NexusOS itself depends on and gates the rest" -ForegroundColor DarkGray Write-Host " of the catalog until both are installed:" -ForegroundColor DarkGray Write-Host " - $MemModel (required - memory curator: extracts facts, titles chats)" -ForegroundColor Gray Write-Host " - $EmbedModel (required - semantic recall of past conversations)" -ForegroundColor Gray Write-Host " After that, the Recommended tab detects your VRAM/RAM and flags which" -ForegroundColor DarkGray Write-Host " chat models fit (green = GPU, yellow = CPU/RAM), e.g. $ChatModel." -ForegroundColor DarkGray # -- Make Ollama manual-start (NexusOS owns the lifecycle) ---------------------- Write-Step "Setting Ollama to manual start" # The Ollama desktop app autostarts a server at every login, and the elevated # 'ollama pull' above leaves an elevated server the user-level app cannot stop - # which makes the Start/Stop AI button get stuck. Remove the login autostart and # stop the running server so NexusOS controls Ollama via its Start AI button. try { $ollamaAutostart = Join-Path ([Environment]::GetFolderPath("Startup")) "Ollama.lnk" if (Test-Path $ollamaAutostart) { Remove-Item $ollamaAutostart -Force Write-OK "Removed Ollama login autostart" } # Get-Process, not taskkill: taskkill writes "ERROR: The process ... not # found." to stderr when nothing is running, and with $ErrorActionPreference # = Stop PowerShell 5.1 promotes a native command's stderr to a TERMINATING # error. `2>$null` does not prevent that. So the ordinary case - Ollama # simply is not running - aborted this whole block and printed the warning # below, claiming the install could not configure something it had in fact # already done. Cmdlets have no such trap. foreach ($name in @("ollama app", "ollama")) { Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } Write-OK "Ollama set to manual start" } catch { Write-Warn "Could not adjust Ollama autostart - you can still Start/Stop AI from the app." } # -- Done ---------------------------------------------------------------------- Write-Host "" Write-Host " ==========================================================" -ForegroundColor Green Write-Host " NexusOS is installed!" -ForegroundColor Green Write-Host "" Write-Host " Launch: double-click the NexusOS shortcut on your desktop" -ForegroundColor White Write-Host " (or run powershell -File launch_nexus.ps1)" -ForegroundColor White Write-Host "" Write-Host " The app opens at http://localhost:8000" -ForegroundColor White Write-Host " The AI starts OFF - click 'Start AI' in the sidebar to turn it on." -ForegroundColor White Write-Host "" Write-Host " Terminal: open a NEW terminal (PowerShell or cmd), then run ncp help" -ForegroundColor White Write-Host " ==========================================================" -ForegroundColor Green Write-Host "" Write-Host " This window has to close for ncp to work." -ForegroundColor Yellow Write-Host " A process reads PATH once, when it starts. This shell started before" -ForegroundColor DarkGray Write-Host " the installer put management\ on PATH, so it will never see ncp no" -ForegroundColor DarkGray Write-Host " matter what you run here. The next terminal you open will." -ForegroundColor DarkGray Write-Host "" # Skip the wait entirely when stdin is redirected: nobody is at the keyboard to # press anything, and both ReadKey and Read-Host were confirmed (empirically, # not just in theory) to hang indefinitely in that case instead of failing # fast - Read-Host only fails fast under an explicit -NonInteractive flag, # which is not how the self-elevation Start-Process above launches this script. if (-not [Console]::IsInputRedirected) { Write-Host " Press any key to close this window..." -ForegroundColor Yellow try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } catch { Read-Host | Out-Null } } # $OwnsWindow means this is the elevated instance the self-elevation block above # launched with `-File`, so a clean `exit` closes it - and does so with exit code # 0, which is what lets Windows Terminal's closeOnExit actually close the tab # instead of treating an abrupt kill as a crash and falling back to a fresh # shell in the pane. Without $OwnsWindow we are in a shell the user already had # open (they ran an admin PowerShell and invoked the script directly, so it was # never re-launched with -File) - `exit` there would just return to the prompt # in a session whose PATH is permanently stale, so kill the host instead; the # point is the user cannot accidentally keep using a shell where ncp never # resolves. # Caveat: a Start-Transcript in this window never reaches Stop-Transcript in the # Stop-Process path. The transcript is flushed as it is written, so the content # survives without the closing footer. if ($OwnsWindow) { exit 0 } else { Stop-Process -Id $PID }