install/windows.ps1
Rama master · réplica de lectura del repositorio original.
param(
[Parameter(Position=0)][string]$Command = "",
[string]$InstallDir = "",
[string]$RepoUrl = $env:METIS_AI_REPO_URL,
[string]$DataDir = "",
[string]$AgentCwd = "",
[string]$Port = "3100",
[Alias("Host")][string]$BindHost = "127.0.0.1",
[string]$McpPort = "8787",
[string]$Username = "admin",
[string]$Password = "",
[string]$PasswordFile = "",
[string]$ServiceName = "MetisAI",
[string]$PublicUrl = "",
[string]$Version = "",
[string]$Commit = "",
[switch]$NonInteractive,
[switch]$SkipRuntimeInstall,
[switch]$Native,
[switch]$Docker,
[switch]$ReplaceExisting,
[switch]$DryRun,
[switch]$Help,
[switch]$Yes,
[switch]$KeepData,
[switch]$RemoveData
)
$ErrorActionPreference = "Stop"
if ($Help) {
@"
Usage:
windows.ps1 Guided installation
windows.ps1 -NonInteractive
This script must be invoked with powershell -File. Do not pipe it to iex;
use install.ps1 for the one-line installer.
Options: -InstallDir, -DataDir, -AgentCwd, -Port, -Host, -McpPort,
-Username, -Password, -PasswordFile, -ServiceName, -PublicUrl, -Version, -Commit
-NonInteractive, -SkipRuntimeInstall, -Native, -ReplaceExisting, -DryRun
uninstall [-Yes] [-KeepData] [-InstallDir DIR]
"@ | Write-Host
exit 0
}
if ($Command -eq "uninstall") {
$selfDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$uninstaller = Join-Path $selfDir "uninstall.ps1"
if (-not (Test-Path -LiteralPath $uninstaller)) {
$uninstaller = Join-Path (Join-Path $selfDir "install") "uninstall.ps1"
}
if (-not (Test-Path -LiteralPath $uninstaller)) { throw "Could not find uninstall.ps1 next to windows.ps1." }
$forward = @()
if ($InstallDir) { $forward += @("-InstallDir", $InstallDir) }
if ($ServiceName) { $forward += @("-ServiceName", $ServiceName) }
if ($KeepData) { $forward += "-KeepData" }
if ($RemoveData) { $forward += "-RemoveData" }
if ($DryRun) { $forward += "-DryRun" }
if ($Yes) { $forward += "-Yes" }
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $uninstaller @forward
exit $LASTEXITCODE
}
if (-not $RepoUrl) { $RepoUrl = "https://github.com/f1shyondrugs/metis-ai.git" }
if (-not $InstallDir) {
$InstallDir = if ($env:METIS_AI_INSTALL_DIR) { $env:METIS_AI_INSTALL_DIR } else { Join-Path $HOME "metis-ai" }
}
function Ask([string]$Prompt, [string]$Default) {
if ($NonInteractive) { return $Default }
$value = Read-Host "$Prompt [$Default]"
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function Get-DefaultPublicHost {
$ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike "127.*" -and $_.IPAddress -notlike "169.254.*" } |
Select-Object -First 1 -ExpandProperty IPAddress
if ($ip) { return $ip }
return "127.0.0.1"
}
function Require-Command([string]$Name) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "$Name is required." }
}
function Refresh-Path {
$env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" +
[Environment]::GetEnvironmentVariable("Path", "User")
}
function Get-NodeMajor {
$node = Get-Command node -ErrorAction SilentlyContinue
if (-not $node) { return 0 }
try { return [int]((& $node.Source -p "process.versions.node.split('.')[0]")) } catch { return 0 }
}
function Confirm-Install([string]$Name) {
if ($NonInteractive) { return $true }
$answer = Read-Host "$Name is missing or too old. Install/update it automatically now? (Y/n)"
return [string]::IsNullOrWhiteSpace($answer) -or $answer -match "^(y|yes)$"
}
if ($PasswordFile) {
if (-not (Test-Path -LiteralPath $PasswordFile -PathType Leaf)) { throw "Password file is not readable: $PasswordFile" }
$Password = (Get-Content -LiteralPath $PasswordFile -Raw).TrimEnd("`r", "`n")
}
$port = $Port
$mcpPort = $McpPort
$username = $Username
$serviceName = $ServiceName
$dataDir = if ($DataDir) { $DataDir } else { Join-Path $InstallDir "data" }
$agentCwd = if ($AgentCwd) { $AgentCwd } else { $HOME }
if (-not $NonInteractive) {
$InstallDir = Ask "Installation directory" $InstallDir
$dataDir = if ($DataDir) { $DataDir } else { Join-Path $InstallDir "data" }
$aiChatHost = if ($BindHost) { $BindHost } else { "127.0.0.1" }
} else {
$aiChatHost = if ($BindHost) { $BindHost } else { "127.0.0.1" }
}
$passwordPlain = $Password
if ($Password -and $Password.Length -lt 8) { throw "Password must contain at least 8 characters." }
if ($Docker) { throw "Docker installation is no longer available. Use the native installer." }
$Native = $true
$legacyManifest = Join-Path $InstallDir ".metis-ai-install.json"
if ((Test-Path -LiteralPath $legacyManifest) -and (Get-Content -LiteralPath $legacyManifest -Raw | ConvertFrom-Json).installMethod -eq "docker") {
throw "This directory contains a legacy Docker installation. Choose a separate directory for native installation; migrate data separately."
}
if ($Commit -and $Version) { throw "Use either -Version or -Commit, not both." }
if ($Commit) {
if ($Commit -notmatch '^[0-9a-fA-F]{7,40}$') { throw "Commit must be a git SHA." }
} else {
if ([string]::IsNullOrWhiteSpace($Version) -or $Version -eq "latest") {
try {
$release = Invoke-RestMethod -Headers @{ Accept = "application/vnd.github+json" } -Uri "https://api.github.com/repos/f1shyondrugs/metis-ai/releases/latest"
$Version = [string]$release.tag_name
} catch {
throw "Could not resolve the latest stable Metis AI release."
}
}
if ($Version -notmatch '^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') {
throw "The selected stable release tag is invalid or unavailable: $Version"
}
}
# Preflight uses GitHub metadata, so a fresh machine does not need Git yet.
if ($RepoUrl -match '^https://github\.com/(.+?)(?:\.git)?$') {
$repoSlug = $Matches[1]
if ($Commit) {
try {
$commitInfo = Invoke-RestMethod -Headers @{ Accept = "application/vnd.github+json" } -Uri "https://api.github.com/repos/$repoSlug/commits/$Commit"
$Commit = [string]$commitInfo.sha
} catch { throw "The selected commit is unavailable: $Commit" }
if ($Commit -notmatch '^[0-9a-fA-F]{40}$') { throw "GitHub returned no valid commit." }
} else {
if (-not $release) {
try { $release = Invoke-RestMethod -Headers @{ Accept = "application/vnd.github+json" } -Uri "https://api.github.com/repos/$repoSlug/releases/tags/$Version" }
catch { throw "The selected published release is unavailable: $Version" }
}
if (-not ($release.assets | Where-Object { $_.name -eq "SHA256SUMS" })) { throw "The selected release has no installer checksum asset." }
}
}
if (-not $env:METIS_AI_INSTALL_BASE) {
$selectedRef = if ($Commit) { $Commit } else { $Version }
$env:METIS_AI_INSTALL_BASE = "https://raw.githubusercontent.com/f1shyondrugs/metis-ai/$selectedRef"
}
# Account creation takes place in the browser.
$publicHost = if ($aiChatHost -eq "0.0.0.0") { Get-DefaultPublicHost } else { "127.0.0.1" }
$publicUrl = if ($PublicUrl) { $PublicUrl } else { "http://$publicHost`:$port" }
$portNumber = 0
$mcpPortNumber = 0
if (-not [int]::TryParse($port, [ref]$portNumber) -or $portNumber -lt 1 -or $portNumber -gt 65535) {
throw "Web port must be a number between 1 and 65535."
}
if (-not [int]::TryParse($mcpPort, [ref]$mcpPortNumber) -or $mcpPortNumber -lt 1 -or $mcpPortNumber -gt 65535) {
throw "MCP port must be a number between 1 and 65535."
}
if ($port -eq $mcpPort) { throw "Web and MCP ports must be different." }
if ($serviceName -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]*$') {
throw "Service name may contain letters, numbers, underscores and hyphens."
}
$existingServiceDir = ""
$appKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\$serviceName"
try {
$existingServiceDir = (Get-ItemProperty -LiteralPath $appKey -ErrorAction Stop).InstallLocation
} catch {}
try {
$existingRun = (Get-ItemProperty -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "$serviceName-app" -ErrorAction Stop)."$serviceName-app"
if ($existingRun -and -not $existingServiceDir) {
$cmdPath = [string]$existingRun.Trim().Trim('"')
if (Test-Path -LiteralPath $cmdPath) { $existingServiceDir = Split-Path -Parent $cmdPath }
}
} catch {}
if ($DryRun) {
Write-Host "Dry run; no files or services will be changed."
Write-Host " os: windows"
Write-Host " install dir: $InstallDir"
Write-Host " data dir: $dataDir"
Write-Host " agent cwd: $agentCwd"
Write-Host " bind: ${aiChatHost}:${port}"
Write-Host " mcp port: $mcpPort"
Write-Host " service name: $serviceName"
Write-Host " public url: $publicUrl"
Write-Host " username: $username"
Write-Host " native: $Native"
if ($existingServiceDir) { Write-Host " existing: $serviceName-app at $existingServiceDir" } else { Write-Host " existing: none" }
exit 0
}
function Test-OwnedDockerPort([int]$Number) {
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { return $false }
try {
$ownedPorts = & docker ps --filter "label=com.docker.compose.project.working_dir=$InstallDir" --format "{{.Ports}}" 2>$null
if ($LASTEXITCODE -ne 0) { return $false }
return [bool](@($ownedPorts | Where-Object { $_ -match ":$Number->" }).Count)
} catch { return $false }
}
function Assert-AvailablePorts([string]$WebPort, [string]$GatewayPort) {
if ($WebPort -eq $GatewayPort) { throw "Web and MCP ports must be different." }
foreach ($entry in @(@{ Name = "Web"; Port = $WebPort }, @{ Name = "MCP"; Port = $GatewayPort })) {
$number = 0
if (-not [int]::TryParse($entry.Port, [ref]$number) -or $number -lt 1 -or $number -gt 65535) {
throw "$($entry.Name) port must be between 1 and 65535."
}
if (Test-OwnedDockerPort $number) { continue }
$owners = @(Get-NetTCPConnection -State Listen -LocalPort $number -ErrorAction SilentlyContinue)
foreach ($owner in $owners) {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$($owner.OwningProcess)" -ErrorAction SilentlyContinue
$owned = $proc -and $proc.CommandLine -and
$proc.CommandLine.IndexOf([IO.Path]::GetFullPath($InstallDir) + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and
$proc.CommandLine -match 'server\.mjs|gateway-core\.mjs|MetisHost\.exe'
# Legacy node children may use relative paths; verify their PowerShell parent.
if (-not $owned -and $proc) {
$parent = Get-CimInstance Win32_Process -Filter "ProcessId=$($proc.ParentProcessId)" -ErrorAction SilentlyContinue
$owned = $parent -and $parent.CommandLine -and
$parent.CommandLine.IndexOf([IO.Path]::GetFullPath($InstallDir) + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and
$parent.CommandLine -match 'run-service\.ps1'
}
if (-not $owned) {
throw "$($entry.Name) port $number is already occupied by $($proc.Name) (PID $($owner.OwningProcess)). Metis has not been started. Choose a free -Port / -McpPort or close the conflicting application (for example OpenWebUI)."
}
}
if ($owners.Count -eq 0) {
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $number)
$listener.Server.ExclusiveAddressUse = $true
try { $listener.Start() } catch { throw "$($entry.Name) port $number cannot be bound. Choose a free -Port / -McpPort. $($_.Exception.Message)" }
finally { $listener.Stop() }
}
}
}
$script:ReplaceDataStash = $null
$script:ReplaceEnvStash = $null
function Get-EnvStashPath([string]$Dir) {
$full = [IO.Path]::GetFullPath($Dir).TrimEnd('\')
return Join-Path (Split-Path -Parent $full) (".$(Split-Path -Leaf $full).metis-keep-env")
}
function Get-EnvKey([string]$Line) {
$trim = $Line.Trim()
if (-not $trim -or $trim.StartsWith('#')) { return "" }
$idx = $trim.IndexOf('=')
if ($idx -lt 1) { return "" }
return $trim.Substring(0, $idx)
}
function Save-ExistingEnv([string]$Dir) {
$src = Join-Path $Dir ".env"
if (-not (Test-Path -LiteralPath $src)) { return }
$script:ReplaceEnvStash = Get-EnvStashPath $Dir
Copy-Item -LiteralPath $src -Destination $script:ReplaceEnvStash -Force
Write-Host "Kept previous .env at $($script:ReplaceEnvStash)"
}
function Adopt-EnvStash([string]$Dir) {
if ($script:ReplaceEnvStash -and (Test-Path -LiteralPath $script:ReplaceEnvStash)) { return }
$candidate = Get-EnvStashPath $Dir
if (Test-Path -LiteralPath $candidate) {
$script:ReplaceEnvStash = $candidate
return
}
Save-ExistingEnv $Dir
}
function Merge-PreservedEnv([string]$Dest) {
if (-not $script:ReplaceEnvStash -or -not (Test-Path -LiteralPath $script:ReplaceEnvStash) -or -not (Test-Path -LiteralPath $Dest)) { return }
$structural = @{}
foreach ($key in @(
'AI_CHAT_ROOT', 'AI_CHAT_INSTALL_DIR', 'METIS_NODE_BIN', 'METIS_NODE_HOME',
'CHAT_DATA_DIR', 'METIS_DATA_DIR', 'AGENT_CWD', 'METIS_WORKSPACE',
'AI_CHAT_MCP_STATE_DIR', 'METIS_DOCKER', 'AI_CHAT_SERVICE_NAME', 'MCP_SDK_ROOT'
)) { $structural[$key] = $true }
$oldLines = [ordered]@{}
foreach ($line in Get-Content -LiteralPath $script:ReplaceEnvStash) {
$k = Get-EnvKey $line
if ($k) { $oldLines[$k] = $line }
}
$used = @{}
$out = New-Object System.Collections.Generic.List[string]
foreach ($line in Get-Content -LiteralPath $Dest) {
$k = Get-EnvKey $line
if ($k -and -not $structural.ContainsKey($k) -and $oldLines.Contains($k)) {
$out.Add([string]$oldLines[$k])
$used[$k] = $true
} else {
$out.Add($line)
if ($k) { $used[$k] = $true }
}
}
foreach ($k in $oldLines.Keys) {
if (-not $used.ContainsKey($k) -and -not $structural.ContainsKey($k)) {
$out.Add([string]$oldLines[$k])
}
}
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[IO.File]::WriteAllText($Dest, (($out -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine), $utf8NoBom)
Remove-Item -LiteralPath $script:ReplaceEnvStash -Force
$script:ReplaceEnvStash = $null
Write-Host "Merged previous .env into $Dest (old values kept, new keys added)."
}
function Get-ExistingDataDir([string]$Dir) {
$envFile = Join-Path $Dir ".env"
if (Test-Path -LiteralPath $envFile) {
$line = Get-Content -LiteralPath $envFile | Where-Object { $_ -match '^CHAT_DATA_DIR=' } | Select-Object -First 1
if ($line) {
$value = $line.Substring('CHAT_DATA_DIR='.Length).Trim().Trim('"')
if ($value) { return $value }
}
}
$manifestPath = Join-Path $Dir ".metis-ai-install.json"
if (Test-Path -LiteralPath $manifestPath) {
try {
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.dataDir) { return [string]$manifest.dataDir }
} catch {}
}
$nested = Join-Path $Dir "data"
if (Test-Path -LiteralPath $nested) { return $nested }
return ""
}
function Test-PathInside([string]$Inner, [string]$Outer) {
$innerFull = [IO.Path]::GetFullPath($Inner).TrimEnd('\')
$outerFull = [IO.Path]::GetFullPath($Outer).TrimEnd('\')
return ($innerFull -eq $outerFull) -or $innerFull.StartsWith($outerFull + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)
}
function Test-InstallProcess($Process, [string[]]$Roots) {
if ($Process.Name -notin @("node.exe", "esbuild.exe")) { return $false }
foreach ($root in $Roots) {
if (-not $root) { continue }
$prefix = $root.TrimEnd('\') + '\'
if ($Process.Name -eq "node.exe" -and $Process.CommandLine -and
$Process.CommandLine.IndexOf($prefix, [StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
if ($Process.Name -eq "esbuild.exe" -and $Process.ExecutablePath -and
$Process.ExecutablePath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $true }
}
return $false
}
function Stop-ScriptHost([string]$Dir) {
$scriptHost = Join-Path $Dir "windows-script-host.mjs"
if (-not (Test-Path -LiteralPath $scriptHost)) { return }
$node = (Get-Command node -ErrorAction Stop).Source
& $node $scriptHost --stop
if ($LASTEXITCODE -ne 0) { throw "Metis script host did not stop. Check host.log." }
}
function Uninstall-DetectedInstall([string]$Dir) {
if (-not $Dir -or $Dir -eq [IO.Path]::GetPathRoot($Dir) -or $Dir -eq $HOME) {
throw "Refusing to uninstall an unsafe install directory: $Dir"
}
Write-Host "Uninstalling existing Metis AI at $Dir (data kept)."
$data = Get-ExistingDataDir $Dir
Stop-ScriptHost $Dir
$hostExe = Join-Path $Dir "MetisHost.exe"
$hostProcesses = @(Get-CimInstance Win32_Process -Filter "Name='MetisHost.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq [IO.Path]::GetFullPath($hostExe) })
if ($hostProcesses.Count) {
Start-Process -FilePath $hostExe -ArgumentList "--stop" -Wait
Start-Sleep -Seconds 1
$runningHost = @(Get-CimInstance Win32_Process -Filter "Name='MetisHost.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq [IO.Path]::GetFullPath($hostExe) })
foreach ($proc in $runningHost) { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue }
}
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
foreach ($suffix in @("app", "worker", "mcp")) {
$task = "$serviceName-$suffix"
Remove-ItemProperty -LiteralPath $runKey -Name $task -ErrorAction SilentlyContinue
cmd.exe /c "schtasks /Delete /TN `"$task`" /F >nul 2>&1" | Out-Null
}
$rootNorm = [IO.Path]::GetFullPath($Dir).TrimEnd('\')
$rootAliases = @($Dir, $rootNorm)
$oldManifestPath = Join-Path $Dir ".metis-ai-install.json"
if (Test-Path -LiteralPath $oldManifestPath) {
$oldManifest = Get-Content -LiteralPath $oldManifestPath -Raw | ConvertFrom-Json
if ($oldManifest.installDir -and [IO.Path]::GetFullPath([string]$oldManifest.installDir).TrimEnd('\') -eq $rootNorm) {
$rootAliases += [string]$oldManifest.installDir
}
}
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { Test-InstallProcess $_ $rootAliases } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Save-ExistingEnv $Dir
if ($data -and (Test-Path -LiteralPath $data) -and (Test-PathInside $data $Dir)) {
$parent = Split-Path -Parent $rootNorm
$script:ReplaceDataStash = Join-Path $parent (".$(Split-Path -Leaf $rootNorm).metis-keep-data")
if (Test-Path -LiteralPath $script:ReplaceDataStash) { Remove-Item -LiteralPath $script:ReplaceDataStash -Recurse -Force }
Move-Item -LiteralPath $data -Destination $script:ReplaceDataStash
Write-Host "Kept nested data at $($script:ReplaceDataStash)"
}
Start-Sleep -Seconds 1
if (Test-Path -LiteralPath $rootNorm) {
cmd.exe /c "rmdir /s /q `"\\?\$rootNorm`"" | Out-Null
}
}
function Restore-StashedData([string]$Dest) {
if (-not $script:ReplaceDataStash -or -not (Test-Path -LiteralPath $script:ReplaceDataStash)) { return }
$destParent = Split-Path -Parent $Dest
if ($destParent) { New-Item -ItemType Directory -Force -Path $destParent | Out-Null }
if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
Move-Item -LiteralPath $script:ReplaceDataStash -Destination $Dest
$script:ReplaceDataStash = $null
Write-Host "Restored kept data to $Dest"
}
if ($existingServiceDir) {
$existingFull = [IO.Path]::GetFullPath($existingServiceDir)
$installFull = [IO.Path]::GetFullPath($InstallDir)
$same = $existingFull -eq $installFull
$choice = ""
if ($ReplaceExisting) {
$choice = "r"
} elseif ($NonInteractive) {
if ($same) { $choice = "u" }
} else {
Write-Host "Existing Metis AI detected."
Write-Host " service: $serviceName-app"
Write-Host " directory: $existingServiceDir"
Write-Host "[u] Upgrade that install"
Write-Host "[r] Replace it (uninstall, keep data, then continue)"
Write-Host "[n] Uninstall and exit (keeps data)"
Write-Host "[a] Abort"
$choice = Read-Host "Choice [u/r/n/a]"
}
switch -Regex ($choice) {
'^[rR]$' {
$InstallDir = $existingServiceDir
if (-not $DataDir) { $dataDir = Join-Path $InstallDir "data" }
Uninstall-DetectedInstall $InstallDir
$existingServiceDir = ""
}
'^[uU]$' {
$InstallDir = $existingServiceDir
Write-Host "Existing Metis AI install detected: $serviceName-app is registered in $InstallDir. Upgrading in place."
}
'^[aA]$' {
Write-Host "Aborted."
exit 0
}
'^[nN]$' {
$self = $MyInvocation.MyCommand.Path
$forward = @("uninstall", "-InstallDir", $existingServiceDir, "-Yes", "-KeepData")
if ($DryRun) { $forward += "-DryRun" }
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $self @forward
exit $LASTEXITCODE
}
default {
throw "Metis AI is already installed as $serviceName-app in $existingServiceDir. Re-run and choose upgrade/replace/uninstall, or pass -ReplaceExisting."
}
}
}
# Check effective preserved ports before dependency installation or building.
$preflightEnv = Join-Path $InstallDir ".env"
if (Test-Path -LiteralPath $preflightEnv) {
foreach ($line in Get-Content -LiteralPath $preflightEnv) {
if ($line -match '^PORT=(.+)$') { $port = $Matches[1].Trim().Trim('"') }
if ($line -match '^MCP_PORT=(.+)$') { $mcpPort = $Matches[1].Trim().Trim('"') }
}
}
if ($Native -and ((Test-OwnedDockerPort ([int]$port)) -or (Test-OwnedDockerPort ([int]$mcpPort)))) {
throw "This installation is running in Docker. Keep its current mode for upgrade, or stop its Compose stack before migrating with -Native."
}
if ($Docker -and $existingServiceDir) {
$oldManifest = Join-Path $InstallDir ".metis-ai-install.json"
if ((Test-Path -LiteralPath $oldManifest) -and (Get-Content -LiteralPath $oldManifest -Raw | ConvertFrom-Json).installMethod -eq "native") {
throw "This is a native installation. Upgrade without -Docker; migrate its runtime separately."
}
}
Assert-AvailablePorts $port $mcpPort
$publicUrl = if ($PublicUrl) { $PublicUrl } else { "http://$publicHost`:$port" }
$useDocker = [bool]$Docker
$previousManifest = Join-Path $InstallDir ".metis-ai-install.json"
if (-not $Native -and -not $Docker -and (Test-Path -LiteralPath $previousManifest)) {
$useDocker = (Get-Content -LiteralPath $previousManifest -Raw | ConvertFrom-Json).installMethod -eq "docker"
}
if ($useDocker) {
Require-Command docker
& docker info | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Docker is not running. Start Docker Desktop or omit -Docker for native installation." }
& docker compose version | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Docker Compose is required with -Docker." }
}
if (-not $SkipRuntimeInstall) {
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
throw "winget is required to install Git and Node.js automatically."
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
if (-not (Confirm-Install "Git")) { throw "git is required." }
winget install --id Git.Git --accept-source-agreements --accept-package-agreements
}
if (-not $useDocker -and (Get-NodeMajor) -lt 22) {
if (-not (Confirm-Install "Node.js 22 or newer")) { throw "Node.js 22 or newer is required." }
winget install --id OpenJS.NodeJS.LTS --source winget --accept-source-agreements --accept-package-agreements
}
Refresh-Path
}
Require-Command git
if (-not $useDocker -and (Get-NodeMajor) -lt 22) { throw "Node.js 22 or newer is required." }
if (Test-Path (Join-Path $InstallDir ".git")) {
git -C $InstallDir fetch --force origin
if ($LASTEXITCODE -ne 0) { throw "Could not fetch Metis AI commits." }
git -C $InstallDir fetch --tags --force
if ($LASTEXITCODE -ne 0) { throw "Could not fetch Metis AI release tags." }
} elseif ((Test-Path $InstallDir) -and (Get-ChildItem -Force $InstallDir | Select-Object -First 1)) {
throw "Installation directory exists and is not a Metis AI checkout: $InstallDir"
} else {
New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) | Out-Null
git clone $RepoUrl $InstallDir
if ($LASTEXITCODE -ne 0) { throw "Could not clone Metis AI." }
}
if ($Commit) {
$updateRef = $Commit
git -C $InstallDir checkout --force --detach $updateRef
} else {
$updateRef = $Version
git -C $InstallDir checkout --force $updateRef
}
if ($LASTEXITCODE -ne 0) { throw "Could not check out Metis AI $updateRef." }
# Replace tracked local edits and divergent commits, preserving ignored install state.
git -C $InstallDir reset --hard $updateRef
if ($LASTEXITCODE -ne 0) { throw "Could not reset Metis AI to $updateRef." }
Restore-StashedData $dataDir
function Get-PnpmCommand {
$existing = (Get-Command pnpm.cmd -ErrorAction SilentlyContinue).Source
if ($existing) { return [string]$existing }
$runtimePrefix = Join-Path $InstallDir ".runtime"
$candidates = @(
(Join-Path $runtimePrefix "pnpm.cmd"),
(Join-Path $runtimePrefix "node_modules\.bin\pnpm.cmd")
)
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) { return [string]$candidate }
}
$npmCommand = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source
if (-not $npmCommand) { throw "npm is required to install pnpm without Administrator access." }
New-Item -ItemType Directory -Force -Path $runtimePrefix | Out-Null
& $npmCommand install --global --prefix $runtimePrefix pnpm@9 | Out-Null
$env:Path = "$runtimePrefix;$runtimePrefix\node_modules\.bin;" + $env:Path
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) { return [string]$candidate }
}
$refreshed = (Get-Command pnpm.cmd -ErrorAction SilentlyContinue).Source
if ($refreshed) { return [string]$refreshed }
throw "pnpm is required."
}
$pnpmCommand = $null
$nodeBin = $null
if (-not $useDocker) {
$pnpmCommand = [string](Get-PnpmCommand)
$nodeBin = (Get-Command node).Source
}
$randomHex = { -join (1..32 | ForEach-Object { "{0:x2}" -f (Get-Random -Maximum 256) }) }
$chatPassword = & $randomHex
$secretsKey = & $randomHex
$mcpToken = & $randomHex
New-Item -ItemType Directory -Force -Path $dataDir, $agentCwd | Out-Null
Adopt-EnvStash $InstallDir
$dockerEnv = ""
if ($useDocker) {
$dockerEnv = @"
METIS_WORKSPACE=$agentCwd
METIS_DATA_DIR=$dataDir
AI_CHAT_BIND=$aiChatHost
METIS_DOCKER=1
AGENT_CWD=/workspace
CHAT_DATA_DIR=/data
METIS_AI_BOOTSTRAP_USERNAME=$username
METIS_AI_BOOTSTRAP_PASSWORD=$passwordPlain
METIS_AI_BOOTSTRAP_OPTIONAL=1
"@
} else {
$hostOsUsername = (& $nodeBin -p "require('node:os').userInfo().username").Trim()
if ($LASTEXITCODE -ne 0 -or -not $hostOsUsername) { throw "Could not resolve the Windows installation account." }
$mcpSdkRoot = Join-Path $InstallDir "node_modules\@modelcontextprotocol\sdk"
$dockerEnv = "METIS_NODE_BIN=$nodeBin" + [Environment]::NewLine +
"METIS_HOST_OS_USERNAME=$hostOsUsername" + [Environment]::NewLine +
"MCP_SDK_ROOT=$mcpSdkRoot"
}
$envLines = @"
APP_NAME=Metis AI
PORT=$port
AI_CHAT_HOST=$aiChatHost
CHAT_USERNAME=$username
CHAT_PASSWORD=$chatPassword
CHAT_DATA_DIR=$dataDir
AGENT_CWD=$agentCwd
AI_CHAT_ROOT=$InstallDir
AI_CHAT_INSTALL_DIR=$InstallDir
AI_CHAT_PUBLIC_URL=$publicUrl
AI_CHAT_INTERNAL_ORIGIN=http://127.0.0.1:$port
AI_CHAT_SERVICE_NAME=$serviceName
AI_CHAT_WORKER_CONCURRENCY=25
AI_CHAT_MCP_STATE_DIR=$(Join-Path $dataDir "mcp-state")
AI_CHAT_INTERNAL_URL=http://127.0.0.1:$port/api/internal/mcp-question
AI_CHAT_WORKSPACE_URL=http://127.0.0.1:$port/api/internal/mcp-workspace
AI_CHAT_CHAT_URL=http://127.0.0.1:$port/api/internal/mcp-chat
AI_CHAT_NOTES_URL=http://127.0.0.1:$port/api/internal/mcp-notes
AI_CHAT_MEMORY_URL=http://127.0.0.1:$port/api/internal/mcp-memory
AI_CHAT_BROWSER_URL=http://127.0.0.1:$port/api/internal/browser
AI_CHAT_AGENT_STATE_URL=http://127.0.0.1:$port/api/internal/mcp-agent-state
AI_CHAT_SUBAGENT_URL=http://127.0.0.1:$port/api/internal/mcp-subagent
AI_CHAT_AUTOMATION_URL=http://127.0.0.1:$port/api/internal/mcp-automation
AI_CHAT_FILE_URL=http://127.0.0.1:$port/api/internal/mcp-file
AI_CHAT_SECRETS_KEY=$secretsKey
MCP_PORT=$mcpPort
MCP_PUBLIC_URL=http://127.0.0.1:$mcpPort
MCP_BEARER_TOKEN=$mcpToken
MCP_ALLOW_REMOTE_ADMIN=false
MCP_ENABLE_REMOTE_SERVERS=false
MCP_ENABLE_OPTIONAL_SERVERS=false
$dockerEnv
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText((Join-Path $InstallDir ".env"), $envLines.Trim() + [Environment]::NewLine, $utf8NoBom)
Merge-PreservedEnv (Join-Path $InstallDir ".env")
$mergedEnv = Join-Path $InstallDir ".env"
foreach ($line in Get-Content -LiteralPath $mergedEnv) {
$k = Get-EnvKey $line
if (-not $k) { continue }
$v = $line.Substring($k.Length + 1).Trim().Trim('"')
if ($k -eq 'PORT' -and $v -match '^[0-9]+$') { $port = $v }
if ($k -eq 'MCP_PORT' -and $v -match '^[0-9]+$') { $mcpPort = $v }
if ($k -eq 'AI_CHAT_PUBLIC_URL') { $publicUrl = $v }
}
Assert-AvailablePorts $port $mcpPort
if ($useDocker) {
$reloadPs1 = @"
# Apply .env and published-port changes. `docker compose restart` keeps the old config.
Set-Location -LiteralPath $PSScriptRoot
Remove-Item Env:PORT,Env:MCP_PORT,Env:AI_CHAT_HOST,Env:AI_CHAT_BIND,Env:METIS_DATA_DIR,Env:METIS_WORKSPACE,Env:METIS_IMAGE -ErrorAction SilentlyContinue
docker compose --env-file .env up -d --remove-orphans --force-recreate
"@
Set-Content -LiteralPath (Join-Path $InstallDir "reload.ps1") -Value $reloadPs1.Trim() -Encoding utf8
Push-Location $InstallDir
try {
Remove-Item Env:PORT,Env:MCP_PORT,Env:AI_CHAT_HOST,Env:AI_CHAT_BIND,Env:METIS_DATA_DIR,Env:METIS_WORKSPACE,Env:METIS_IMAGE -ErrorAction SilentlyContinue
docker compose --env-file .env up -d --build --remove-orphans
} finally {
Pop-Location
}
} else {
Push-Location $InstallDir
try {
$previousNodeEnv = $env:NODE_ENV
Remove-Item Env:NODE_ENV -ErrorAction SilentlyContinue
& $pnpmCommand install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw "Dependency installation failed." }
$env:CHAT_DATA_DIR = $dataDir
& node scripts/sync-provider-clis.mjs
if ($LASTEXITCODE -ne 0) { throw "Provider CLI synchronization failed." }
& $pnpmCommand exec playwright install chromium
if ($LASTEXITCODE -ne 0) { throw "Browser installation failed." }
$env:METIS_AI_BOOTSTRAP_USERNAME = $username
$env:METIS_AI_BOOTSTRAP_PASSWORD = $passwordPlain
$env:METIS_AI_BOOTSTRAP_OPTIONAL = "1"
if ($passwordPlain) {
& $pnpmCommand exec tsx scripts/bootstrap-user.ts
if ($LASTEXITCODE -ne 0) { throw "User bootstrap failed." }
}
$previousDistDir = $env:NEXT_DIST_DIR
$activeDistDir = ""
foreach ($line in Get-Content -LiteralPath $mergedEnv) {
if ($line -match '^NEXT_DIST_DIR=(.+)$') { $activeDistDir = $Matches[1].Trim().Trim('"') }
}
$nextDistDir = if ($activeDistDir -eq ".next-a") { ".next-b" } else { ".next-a" }
$env:NEXT_DIST_DIR = $nextDistDir
$previousNodeOptions = $env:NODE_OPTIONS
try {
$buildNodeOptions = & node scripts/build-node-options.cjs
if ($LASTEXITCODE -ne 0) { throw "Could not determine the production build memory limit." }
$env:NODE_OPTIONS = $buildNodeOptions
& $pnpmCommand build
if ($LASTEXITCODE -ne 0) { throw "Production build failed; existing services were not restarted." }
} finally {
if ($null -ne $previousDistDir) { $env:NEXT_DIST_DIR = $previousDistDir } else { Remove-Item Env:NEXT_DIST_DIR -ErrorAction SilentlyContinue }
if ($null -ne $previousNodeOptions) { $env:NODE_OPTIONS = $previousNodeOptions } else { Remove-Item Env:NODE_OPTIONS -ErrorAction SilentlyContinue }
}
$runtimeEnv = @(Get-Content -LiteralPath $mergedEnv | Where-Object { $_ -notmatch '^NEXT_DIST_DIR=' })
[IO.File]::WriteAllText($mergedEnv, ($runtimeEnv + "NEXT_DIST_DIR=$nextDistDir") -join [Environment]::NewLine, $utf8NoBom)
} finally {
if ($null -ne $previousNodeEnv) { $env:NODE_ENV = $previousNodeEnv }
Pop-Location
Remove-Item Env:METIS_AI_BOOTSTRAP_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:METIS_AI_BOOTSTRAP_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:METIS_AI_BOOTSTRAP_OPTIONAL -ErrorAction SilentlyContinue
}
$runner = Join-Path $InstallDir "run-service.ps1"
@"
`$ErrorActionPreference = "Stop"
Get-Content -LiteralPath (Join-Path `$PSScriptRoot ".env") | Where-Object { `$_ -and -not `$_.StartsWith("#") } | ForEach-Object {
`$pair = `$_ -split "=", 2
if (`$pair.Count -eq 2) { [Environment]::SetEnvironmentVariable(`$pair[0].Trim([char]0xFEFF), `$pair[1], "Process") }
}
Set-Location `$PSScriptRoot
`$node = `$env:METIS_NODE_BIN
if (-not `$node -or -not (Test-Path -LiteralPath `$node)) { `$node = (Get-Command node).Source }
& `$node @args
"@ | Set-Content -LiteralPath $runner -Encoding ascii
}
# GUI host and hidden child processes never allocate a console.
$hostExe = Join-Path $InstallDir "MetisHost.exe"
$hostNew = Join-Path $InstallDir "MetisHost.new.exe"
$csc = Join-Path $env:SystemRoot "Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if (-not (Test-Path -LiteralPath $csc)) {
$csc = Join-Path $env:SystemRoot "Microsoft.NET\Framework\v4.0.30319\csc.exe"
}
$hostSource = Join-Path $InstallDir "MetisHost.cs"
@'
// Compiled by the installer with the Windows .NET Framework compiler (/target:winexe).
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Windows.Forms;
internal static class MetisHost {
static string root = Path.GetFullPath(AppDomain.CurrentDomain.BaseDirectory).TrimEnd(Path.DirectorySeparatorChar);
static string logDir;
static readonly object logLock = new object();
static void Log(string name, string line) {
if (line == null) return;
lock (logLock) File.AppendAllText(Path.Combine(logDir, name + ".log"),
DateTime.UtcNow.ToString("o") + " " + line + Environment.NewLine);
}
static Process Start(string exe, string args, string name) {
var p = new Process();
p.StartInfo = new ProcessStartInfo(exe, args) {
WorkingDirectory = root, UseShellExecute = false, CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden, RedirectStandardOutput = true,
RedirectStandardError = true
};
p.OutputDataReceived += (s, e) => Log(name, e.Data);
p.ErrorDataReceived += (s, e) => Log(name, e.Data);
p.Start(); p.BeginOutputReadLine(); p.BeginErrorReadLine(); return p;
}
static string Env(string key, string fallback) {
return Environment.GetEnvironmentVariable(key) ?? fallback;
}
static void CheckPort(string key, string fallback) {
int port = int.Parse(Env(key, fallback));
var listener = new TcpListener(IPAddress.Any, port);
listener.Server.ExclusiveAddressUse = true;
try { listener.Start(); }
catch (SocketException) { throw new Exception("Port " + port + " (" + key +
") is occupied or unavailable. Close the conflicting application or change " + key + " in " + Path.Combine(root, ".env")); }
finally { listener.Stop(); }
}
static void OpenBrowser() {
string url = "http://127.0.0.1:" + Env("PORT", "3100");
for (int i = 0; i < 60; i++) {
try {
var request = (HttpWebRequest)WebRequest.Create(url + "/api/status");
request.Timeout = 2000; request.AllowAutoRedirect = false;
using (var response = request.GetResponse())
using (var reader = new StreamReader(response.GetResponseStream())) {
string body = reader.ReadToEnd();
if (body.Contains("\"authenticated\"") && body.Contains("\"worker\"") && body.Contains("\"mcp\"")) {
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); return;
}
}
} catch (WebException) {}
Thread.Sleep(1000);
}
MessageBox.Show("Metis did not become ready at " + url + ". Check " + logDir +
" for startup errors and port conflicts.", "Metis AI", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
[STAThread]
static void Main(string[] args) {
bool open = Array.IndexOf(args, "--open") >= 0;
bool stop = Array.IndexOf(args, "--stop") >= 0;
try {
foreach (string line in File.ReadAllLines(Path.Combine(root, ".env"))) {
int pos = line.IndexOf('=');
if (pos > 0 && !line.TrimStart().StartsWith("#"))
Environment.SetEnvironmentVariable(line.Substring(0, pos).Trim().Trim('\uFEFF'),
line.Substring(pos + 1).Trim().Trim('"'));
}
Environment.SetEnvironmentVariable("NODE_ENV", "production");
logDir = Env("CHAT_DATA_DIR", Path.Combine(root, "data"));
Directory.CreateDirectory(logDir);
string id;
using (var hash = SHA256.Create()) id = BitConverter.ToString(hash.ComputeHash(
Encoding.UTF8.GetBytes(root.ToLowerInvariant()))).Replace("-", "");
using (var stopEvent = new EventWaitHandle(false, EventResetMode.ManualReset, "Local\\MetisStop-" + id)) {
if (stop) { stopEvent.Set(); return; }
bool created;
using (var mutex = new Mutex(true, "Local\\MetisHost-" + id, out created)) {
if (!created) { if (open) OpenBrowser(); return; }
stopEvent.Reset();
var children = new List<Process>();
try {
if (Env("PORT", "3100") == Env("MCP_PORT", "8787"))
throw new Exception("PORT and MCP_PORT must be different.");
bool docker = Env("METIS_DOCKER", "0") == "1";
if (!docker) { CheckPort("PORT", "3100"); CheckPort("MCP_PORT", "8787"); }
string node = Env("METIS_NODE_BIN", "node.exe");
string tsx = "--import tsx ";
string[] commands = {
tsx + "\"" + Path.Combine(root, "server.mjs") + "\"",
tsx + "\"" + Path.Combine(root, "worker.ts") + "\"",
"\"" + Path.Combine(root, "lib/mcp-core/gateway-core.mjs") + "\""
};
string[] names = { "app", "worker", "mcp" };
if (docker) {
using (var compose = Start("docker.exe", "compose --env-file .env up -d --remove-orphans", "host")) {
compose.WaitForExit();
if (compose.ExitCode != 0) throw new Exception("Docker Compose failed. Check host.log.");
}
} else {
for (int i = 0; i < commands.Length; i++) children.Add(Start(node, commands[i], names[i]));
}
if (open) new Thread(OpenBrowser) { IsBackground = true }.Start();
while (!stopEvent.WaitOne(2000)) {
for (int i = 0; i < children.Count; i++) {
if (!children[i].HasExited) continue;
Log("host", names[i] + " exited; restarting.");
children[i].Dispose(); children[i] = Start(node, commands[i], names[i]);
}
}
} finally {
foreach (var child in children) {
try { if (!child.HasExited) child.Kill(); child.WaitForExit(); } catch (InvalidOperationException) {}
child.Dispose();
}
mutex.ReleaseMutex();
}
}
}
} catch (Exception e) {
if (logDir != null) Log("host", e.ToString());
if (open) MessageBox.Show(e.Message, "Metis AI", MessageBoxButtons.OK, MessageBoxIcon.Error);
Environment.ExitCode = 1;
}
}
}
'@ | Set-Content -LiteralPath $hostSource -Encoding UTF8
$nativeHostReady = $false
if (Test-Path -LiteralPath $csc) {
try {
& $csc /nologo /target:winexe /reference:System.Windows.Forms.dll "/out:$hostNew" $hostSource
$nativeHostReady = ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $hostNew))
} catch { Write-Host "Native host compilation unavailable: $($_.Exception.Message)" }
}
Stop-ScriptHost $InstallDir
$running = @(Get-CimInstance Win32_Process -Filter "Name='MetisHost.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq [IO.Path]::GetFullPath($hostExe) })
if ($running.Count) {
Start-Process -FilePath $hostExe -ArgumentList "--stop" -Wait
foreach ($proc in $running) {
$process = Get-Process -Id $proc.ProcessId -ErrorAction SilentlyContinue
if ($process -and -not $process.WaitForExit(15000)) { throw "Existing Metis host did not stop. Check host.log." }
}
}
# Migrate only legacy runners/processes belonging to this installation.
$rootNorm = [IO.Path]::GetFullPath($InstallDir).TrimEnd('\') + '\'
$legacy = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and $_.CommandLine.IndexOf($rootNorm, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and
$_.CommandLine -match 'run-service\.ps1|server\.mjs|worker\.ts|gateway-core\.mjs'
})
foreach ($proc in $legacy) {
Get-CimInstance Win32_Process -Filter "ParentProcessId=$($proc.ProcessId)" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq "node.exe" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
}
if ($nativeHostReady) { Move-Item -LiteralPath $hostNew -Destination $hostExe -Force }
@'
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
const installRoot = realpathSync.native(dirname(fileURLToPath(import.meta.url)));
const envPath = join(installRoot, ".env");
const args = process.argv.slice(2);
const allowed = new Set(["--open", "--stop"]);
if (args.some((arg) => !allowed.has(arg))) {
console.error("Usage: windows-script-host.mjs [--open|--stop]");
process.exitCode = 2;
process.exit();
}
const wantsOpen = args.includes("--open");
const wantsStop = args.includes("--stop");
let stopping = false;
function parseEnv(text) {
const result = {};
for (const raw of text.split(/\r?\n/u)) {
const line = raw.replace(/^\uFEFF/u, "").trim();
if (!line || line.startsWith("#")) continue;
const at = line.indexOf("=");
if (at <= 0) continue;
let value = line.slice(at + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
result[line.slice(0, at).trim()] = value;
}
return result;
}
const config = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
for (const [key, value] of Object.entries(config)) process.env[key] = value;
process.env.NODE_ENV = "production";
const dataDir = resolve(config.CHAT_DATA_DIR || config.METIS_DATA_DIR || join(installRoot, "data"));
mkdirSync(dataDir, { recursive: true });
const paths = {
lock: join(dataDir, ".windows-script-host.lock"),
stop: join(dataDir, ".windows-script-host.stop"),
hostLog: join(dataDir, "host.log"),
};
const token = randomBytes(24).toString("hex");
function log(line) {
appendFileSync(paths.hostLog, new Date().toISOString() + " " + line + "\n");
}
function windowsHidden(options = {}) {
return { windowsHide: true, stdio: options.stdio || "ignore", cwd: installRoot, ...options };
}
function runHidden(command, commandArgs, options = {}) {
return spawnSync(command, commandArgs, { ...windowsHidden(options), encoding: "utf8", timeout: options.timeout });
}
function processExists(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
const result = runHidden("tasklist.exe", ["/FI", "PID eq " + pid, "/FO", "CSV", "/NH"], { stdio: ["ignore", "pipe", "ignore"] });
return result.status === 0 && new RegExp('"[^"]+","\\s*' + pid + '","', "u").test(result.stdout || "");
}
function atomicWrite(file, value) {
const temp = file + "." + process.pid + "." + Date.now() + ".tmp";
writeFileSync(temp, value, { encoding: "utf8", mode: 0o600 });
renameSync(temp, file);
}
function readLock() {
try { return JSON.parse(readFileSync(paths.lock, "utf8")); } catch { return null; }
}
function staleLock() {
const current = readLock();
if (!current) {
try { return Date.now() - statSync(paths.lock).mtimeMs > 15000; } catch { return false; }
}
return !processExists(Number(current.pid));
}
function acquireLock() {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const fd = openSync(paths.lock, "wx", 0o600);
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, root: installRoot, token, heartbeat: Date.now() }) + "\n"); } finally { closeSync(fd); }
return true;
} catch (error) {
if (error.code !== "EEXIST" || !staleLock()) return false;
try { renameSync(paths.lock, paths.lock + ".stale." + Date.now()); } catch {}
}
}
return false;
}
function releaseLock() {
const current = readLock();
if (current?.pid === process.pid && current.token === token) rmSync(paths.lock, { force: true });
}
function requestStop() {
const current = readLock();
if (!current || current.root !== installRoot || !processExists(Number(current.pid))) return;
atomicWrite(paths.stop, JSON.stringify({ root: installRoot, pid: current.pid, token: current.token, requestedAt: Date.now() }) + "\n");
for (let i = 0; i < 75; i += 1) {
if (!existsSync(paths.lock)) return;
const latest = readLock();
if (!latest || latest.root !== installRoot) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
}
throw new Error("Metis background host did not stop within 15 seconds.");
}
function removeOwnStop() { try { rmSync(paths.stop, { force: true }); } catch {} }
function appendChildLog(name, chunk) {
if (chunk) appendFileSync(join(dataDir, name + ".log"), chunk);
}
function childCommand(name) {
const node = config.METIS_NODE_BIN || "node.exe";
const rootPath = (value) => isAbsolute(value) ? value : join(installRoot, value);
if (name === "app") return { command: node, args: ["--import", "tsx", rootPath("server.mjs")] };
if (name === "worker") return { command: node, args: ["--import", "tsx", rootPath("worker.ts")] };
return { command: node, args: [rootPath("lib/mcp-core/gateway-core.mjs")] };
}
function startChild(name) {
const command = childCommand(name);
const child = spawn(command.command, command.args, {
cwd: installRoot, env: { ...process.env, NODE_ENV: "production" }, windowsHide: true, stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => appendChildLog(name, chunk));
child.stderr.on("data", (chunk) => appendChildLog(name, chunk));
child.on("error", (error) => log(name + " spawn error: " + error.message));
return child;
}
function stopChild(child, name) {
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return;
log("stopping " + name + " tree pid=" + child.pid);
runHidden("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { timeout: 10000 });
}
function healthyUrl() { return config.AI_CHAT_PUBLIC_URL || "http://127.0.0.1:" + (config.PORT || "3100"); }
async function isHealthy() {
try {
const response = await fetch("http://127.0.0.1:" + (config.PORT || "3100") + "/api/status", { signal: AbortSignal.timeout(3000) });
const status = await response.json();
return response.ok && typeof status.authenticated === "boolean" && status.worker?.ok === true && status.mcp?.ok === true;
} catch { return false; }
}
async function openBrowser() {
const url = healthyUrl();
for (let i = 0; i < 60; i += 1) {
if (stopping) return;
if (await isHealthy()) {
runHidden("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command",
"Start-Process -FilePath '" + url.replaceAll("'", "''") + "'"], { timeout: 5000 });
return;
}
await new Promise((resolveSleep) => setTimeout(resolveSleep, 1000));
}
throw new Error("Metis did not become ready at " + url + ". Check " + dataDir + " for startup errors.");
}
function showError(error) {
log(error.stack || String(error));
if (!wantsOpen) return;
const message = String(error.message || error).replaceAll("'", "''");
runHidden("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command",
"Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('" + message + "','Metis AI','OK','Error') | Out-Null"], { timeout: 10000 });
}
function dockerEnabled() { return config.METIS_DOCKER === "1" || config.METIS_DOCKER === "true"; }
function dockerStart() {
const result = runHidden("docker.exe", ["compose", "--env-file", envPath, "up", "-d", "--remove-orphans"],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
appendChildLog("host", result.stdout || "");
appendChildLog("host", result.stderr || "");
if (result.status !== 0) throw new Error("Docker Compose failed. Check host.log.");
log("docker compose started; stop leaves the compose stack running.");
}
async function main() {
if (wantsStop) { requestStop(); return; }
if (!acquireLock()) {
if (wantsOpen) await openBrowser();
return;
}
removeOwnStop();
const children = [];
const docker = dockerEnabled();
try {
if (config.PORT && config.MCP_PORT && config.PORT === config.MCP_PORT) throw new Error("PORT and MCP_PORT must be different.");
if (!docker) {
for (const portName of ["PORT", "MCP_PORT"]) {
const port = Number(config[portName] || (portName === "PORT" ? 3100 : 8787));
const probe = runHidden("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command",
"$l=New-Object Net.Sockets.TcpListener([Net.IPAddress]::Any," + port + "); try{$l.Start();$l.Stop();exit 0}catch{exit 1}"], { timeout: 5000 });
if (probe.status !== 0) throw new Error("Port " + port + " (" + portName + ") is occupied or unavailable.");
}
for (const name of ["app", "worker", "mcp"]) children.push({ name, child: startChild(name) });
} else dockerStart();
let openPromise;
if (wantsOpen) { openPromise = openBrowser().catch(showError); }
while (true) {
const stopRequest = readLock();
let command = null;
try { command = JSON.parse(readFileSync(paths.stop, "utf8")); } catch {}
if (command && command.root === installRoot && command.token === token && stopRequest?.token === token) break;
for (const entry of children) {
if (entry.child.exitCode !== null) {
log(entry.name + " exited code=" + entry.child.exitCode + "; restarting");
entry.child = startChild(entry.name);
}
}
atomicWrite(paths.lock, JSON.stringify({ pid: process.pid, root: installRoot, token, heartbeat: Date.now() }) + "\n");
await new Promise((resolveSleep) => setTimeout(resolveSleep, 500));
}
} finally {
stopping = true;
for (const entry of children) stopChild(entry.child, entry.name);
removeOwnStop();
releaseLock();
}
}
main().catch((error) => { showError(error); process.exitCode = 1; });
'@ | Set-Content -LiteralPath (Join-Path $InstallDir 'windows-script-host.mjs') -Encoding UTF8
@'
Option Explicit
Dim fso, shell, root, envPath, nodeBin, arg, args, i, command
Set fso = CreateObject("Scripting.FileSystemObject")
Set shell = CreateObject("WScript.Shell")
root = fso.GetParentFolderName(WScript.ScriptFullName)
envPath = fso.BuildPath(root, ".env")
nodeBin = ""
If fso.FileExists(envPath) Then
Dim stream, content, lines, line, p, key, value
Set stream = CreateObject("ADODB.Stream")
stream.Type = 2
stream.Charset = "utf-8"
stream.Open
stream.LoadFromFile envPath
content = stream.ReadText
stream.Close
lines = Split(Replace(content, vbCr, ""), vbLf)
For Each line In lines
line = Trim(line)
If Left(line, 1) <> "#" Then
p = InStr(line, "=")
If p > 1 Then
key = Trim(Left(line, p - 1))
value = Trim(Mid(line, p + 1))
If key = "METIS_NODE_BIN" Then
If Len(value) >= 2 And Left(value, 1) = Chr(34) And Right(value, 1) = Chr(34) Then value = Mid(value, 2, Len(value) - 2)
nodeBin = value
End If
End If
End If
Next
End If
If Len(nodeBin) = 0 Then
nodeBin = shell.ExpandEnvironmentStrings("%ProgramFiles%\nodejs\node.exe")
If Not fso.FileExists(nodeBin) Then nodeBin = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\nodejs\node.exe")
If Not fso.FileExists(nodeBin) Then nodeBin = "node.exe"
End If
args = ""
For i = 0 To WScript.Arguments.Count - 1
arg = WScript.Arguments(i)
If arg <> "--open" And arg <> "--stop" Then
WScript.Echo "Usage: install\windows-launcher.vbs [--open|--stop]"
WScript.Quit 2
End If
args = args & " " & Quote(arg)
Next
command = Quote(nodeBin) & " " & Quote(fso.BuildPath(root, "windows-script-host.mjs")) & args
shell.CurrentDirectory = root
shell.Run command, 0, False
Function Quote(value)
Quote = Chr(34) & Replace(value, Chr(34), Chr(34) & Chr(34)) & Chr(34)
End Function
'@ | Set-Content -LiteralPath (Join-Path $InstallDir 'windows-launcher.vbs') -Encoding Unicode
$hostTarget = $hostExe
$hostArguments = ""
$hostKind = "native"
try {
if (-not $nativeHostReady) { throw "Native host is unavailable." }
Start-Process -FilePath $hostExe -ErrorAction Stop
} catch {
Write-Host "The native host is blocked or unavailable. Using the Windows script host without changing security policies."
$hostTarget = Join-Path $env:SystemRoot "System32\wscript.exe"
$hostArguments = '"' + (Join-Path $InstallDir "windows-launcher.vbs") + '"'
$hostKind = "script"
Start-Process -FilePath $hostTarget -ArgumentList $hostArguments -ErrorAction Stop
}
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
New-Item -Path $runKey -Force | Out-Null
foreach ($suffix in @("app", "worker", "mcp")) {
Remove-ItemProperty -LiteralPath $runKey -Name "$serviceName-$suffix" -ErrorAction SilentlyContinue
}
Set-ItemProperty -LiteralPath $runKey -Name "$serviceName-host" -Value ("`"" + $hostTarget + "`" " + $hostArguments)
$menuDir = Join-Path ([Environment]::GetFolderPath("Programs")) $serviceName
New-Item -ItemType Directory -Force -Path $menuDir | Out-Null
$shell = New-Object -ComObject WScript.Shell
$link = $shell.CreateShortcut((Join-Path $menuDir "Metis AI.lnk"))
$link.TargetPath = $hostTarget
$link.Arguments = ($hostArguments + " --open").Trim()
$link.WorkingDirectory = $InstallDir
$link.Description = "Start Metis AI in the background and open it in your browser"
$link.Save()
New-Item -Path $appKey -Force | Out-Null
Set-ItemProperty -Path $appKey -Name DisplayName -Value "Metis AI"
Set-ItemProperty -Path $appKey -Name Publisher -Value "Metis AI"
Set-ItemProperty -Path $appKey -Name InstallLocation -Value $InstallDir
Set-ItemProperty -Path $appKey -Name DisplayIcon -Value $hostExe
$uninstallCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $InstallDir 'uninstall.ps1')`" -InstallDir `"$InstallDir`""
Set-ItemProperty -Path $appKey -Name UninstallString -Value $uninstallCommand
Remove-Item Env:METIS_AI_BOOTSTRAP_USERNAME,Env:METIS_AI_BOOTSTRAP_PASSWORD,Env:METIS_AI_BOOTSTRAP_OPTIONAL -ErrorAction SilentlyContinue
$manifest = @{
installDir = $InstallDir
dataDir = $dataDir
agentCwd = $agentCwd
serviceName = $serviceName
host = $aiChatHost
os = "windows"
hostKind = $hostKind
installMethod = $(if ($useDocker) { "docker" } else { "native" })
createdAt = [DateTime]::UtcNow.ToString("o")
} | ConvertTo-Json -Compress
Set-Content -LiteralPath (Join-Path $InstallDir ".metis-ai-install.json") -Value $manifest -Encoding utf8
Copy-Item -LiteralPath (Join-Path $InstallDir "install/uninstall.ps1") -Destination (Join-Path $InstallDir "uninstall.ps1") -Force
for ($attempt = 0; $attempt -lt 45; $attempt++) {
try {
$status = Invoke-RestMethod -Uri "http://127.0.0.1:$port/api/status" -TimeoutSec 2
if ($null -eq $status.authenticated -or -not $status.worker -or -not $status.mcp) { throw "The response is not a Metis status response." }
if (-not $status.worker.ok -or -not $status.mcp.ok) { throw "Metis worker or MCP runtime is not ready." }
break
} catch {
if ($attempt -eq 44) { throw "Metis did not become healthy on web port $port. Check $dataDir/app.log and $dataDir/host.log for startup errors or a port conflict." }
Start-Sleep -Seconds 1
}
}
for ($attempt = 0; $attempt -lt 30; $attempt++) {
try {
$html = (Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$port/" -TimeoutSec 5).Content
$asset = [regex]::Match($html, '/_next/static/[^\s"<>]+\.js').Value
if (-not $asset) { throw "No built browser JavaScript asset found." }
$response = Invoke-WebRequest -UseBasicParsing -Uri ("http://127.0.0.1:$port" + $asset) -TimeoutSec 5
if ($response.StatusCode -ne 200) { throw "Browser JavaScript asset is not available." }
break
} catch {
if ($attempt -eq 29) { throw "Metis started, but its browser files are unavailable. Check $dataDir/app.log." }
Start-Sleep -Seconds 1
}
}
for ($attempt = 0; $attempt -lt 20; $attempt++) {
try {
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$mcpPort/health" -TimeoutSec 2
if (-not $health.ok -or $health.name -notlike '*Universal MCP Gateway' -or $health.endpoint -ne '/mcp') { throw "The response is not a Metis MCP gateway." }
break
} catch {
if ($attempt -eq 19) { throw "Metis MCP gateway did not become healthy on port $mcpPort. Check $dataDir/mcp.log and $dataDir/host.log; another application may have taken this port." }
Start-Sleep -Seconds 1
}
}
if ($aiChatHost -eq "0.0.0.0") {
Write-Host "Warning: the web application is reachable on the local network. Use strong credentials and a firewall or trusted TLS reverse proxy."
}
Write-Host "`nMetis AI installed successfully."
Write-Host "Open: $publicUrl (or Metis AI in the Start menu)"
Write-Host "Host: background app, starts automatically at sign-in. Logs: $dataDir"
Write-Host "You can change this. Add: $(Join-Path $InstallDir '.env')"
if ($useDocker) {
Write-Host "Apply: $(Join-Path $InstallDir 'reload.ps1')"
}
Write-Host "Uninstall: $(Join-Path $InstallDir 'uninstall.ps1') -InstallDir `"$InstallDir`" -KeepData"