Ctrl+C in PS 5.1 kills the whole script, so the advertised way to skip the download was also the way to abort the install before its final steps. Now a 'Download them now? [Y/n]' prompt, so declining continues to the end. The default-model seed moves above the pull - it only writes a settings row and was being lost along with the download. A test forbids offering Ctrl+C again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
341 lines
18 KiB
PowerShell
341 lines
18 KiB
PowerShell
#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
|
|
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -OwnsWindow" -Verb RunAs
|
|
exit
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
# -- Desktop shortcut ----------------------------------------------------------
|
|
Write-Step "Creating desktop shortcut"
|
|
try {
|
|
$Launcher = Join-Path $RepoRoot "launch_nexus.ps1"
|
|
$LnkPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "NexusOS.lnk"
|
|
$ws = New-Object -ComObject WScript.Shell
|
|
$lnk = $ws.CreateShortcut($LnkPath)
|
|
$lnk.TargetPath = "powershell.exe"
|
|
$lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$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"
|
|
}
|
|
|
|
# -- 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)")
|
|
Pop-Location
|
|
if ($LASTEXITCODE -ne 0 -or -not $ChatModel -or -not $MemModel) {
|
|
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." }
|
|
|
|
Write-Step "Pulling models ($ChatModel for chat, $MemModel for memory)"
|
|
# A prompt, not "press Ctrl+C to skip": Ctrl+C in PowerShell 5.1 terminates the
|
|
# whole script, so the escape hatch the installer advertised was also the one
|
|
# thing that stopped it finishing - no Ollama cleanup, no summary, no window
|
|
# close. Answering "n" declines the download and the installer carries on.
|
|
Write-Host " These are several GB. You can skip and pull them later from the Models tab." -ForegroundColor DarkGray
|
|
$pullAnswer = Read-Host " Download them now? [Y/n]"
|
|
if ($pullAnswer -match '^\s*(n|no)\s*$') {
|
|
Write-Warn "Model download skipped - get them from the Models tab when you are ready."
|
|
} else {
|
|
# No pipe: 'ollama pull' draws a progress bar with cursor control, and piping it
|
|
# (to Out-Host or anything else) buffers the redraws - the download then shows no
|
|
# output for minutes and reads as a hang. Let it own the console.
|
|
# No try/catch either: a native command that exits non-zero does not throw, so
|
|
# the catch never fired and a failed pull was reported as success.
|
|
ollama pull $ChatModel
|
|
if ($LASTEXITCODE -eq 0) { Write-OK "$ChatModel ready (default chat model)" }
|
|
else { Write-Warn "$ChatModel pull skipped/failed - pull it from the Models tab later." }
|
|
|
|
ollama pull $MemModel
|
|
if ($LASTEXITCODE -eq 0) { Write-OK "$MemModel ready (memory curator)" }
|
|
else { Write-Warn "$MemModel pull skipped/failed - the memory service will fall back to the chat model." }
|
|
}
|
|
|
|
# -- 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 ""
|
|
Read-Host " Press Enter to close this window"
|
|
|
|
# Stop-Process on our own PID, not `exit`: `exit` only ends the window when the
|
|
# host was started with -File. Run interactively - `& .\install-windows.ps1` from
|
|
# a shell already open, which is the common case - it would just return to the
|
|
# prompt in a session whose PATH is permanently stale. Killing the host closes
|
|
# the window either way, which is the point: the user cannot accidentally keep
|
|
# using a shell where ncp will never resolve.
|
|
# Caveat: a Start-Transcript in this window never reaches Stop-Transcript. The
|
|
# transcript is flushed as it is written, so the content survives without the
|
|
# closing footer.
|
|
Stop-Process -Id $PID
|