426 lines
16 KiB
PowerShell
426 lines
16 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Mission Control — Windows Installer
|
|
The mothership for your OpenClaw fleet.
|
|
|
|
.DESCRIPTION
|
|
Installs Mission Control on Windows via local Node.js deployment.
|
|
Mirrors the behaviour of install.sh for Linux/macOS.
|
|
|
|
.PARAMETER Mode
|
|
Deployment mode: "local" (default) or "docker".
|
|
|
|
.PARAMETER Port
|
|
Port the Next.js server listens on (default: 3000).
|
|
|
|
.PARAMETER DataDir
|
|
Custom data directory path (default: .data/ in project root).
|
|
|
|
.PARAMETER InstallDir
|
|
Target directory when cloning from GitHub (default: .\mission-control).
|
|
|
|
.PARAMETER SkipOpenClaw
|
|
Skip OpenClaw fleet checks.
|
|
|
|
.EXAMPLE
|
|
.\install.ps1
|
|
.\install.ps1 -Mode local -Port 8080
|
|
.\install.ps1 -Mode docker
|
|
|
|
.NOTES
|
|
PowerShell uses single-dash parameters: -Mode local, -Port 8080
|
|
Bash-style --local / --docker flags are NOT supported by PowerShell syntax.
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet("local", "docker")]
|
|
[string]$Mode = "",
|
|
|
|
[int]$Port = 3000,
|
|
|
|
[string]$DataDir = "",
|
|
|
|
[string]$InstallDir = "",
|
|
|
|
[switch]$SkipOpenClaw
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────────────
|
|
if (-not $InstallDir) {
|
|
$InstallDir = if ($env:MC_INSTALL_DIR) { $env:MC_INSTALL_DIR } else { Join-Path (Get-Location) "mission-control" }
|
|
}
|
|
$RepoUrl = "https://github.com/builderz-labs/mission-control.git"
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
function Write-MC { param([string]$Msg) Write-Host "[MC] $Msg" -ForegroundColor Blue }
|
|
function Write-Ok { param([string]$Msg) Write-Host "[OK] $Msg" -ForegroundColor Green }
|
|
function Write-Warn { param([string]$Msg) Write-Host "[!!] $Msg" -ForegroundColor Yellow }
|
|
function Write-Err { param([string]$Msg) Write-Host "[ERR] $Msg" -ForegroundColor Red }
|
|
function Stop-WithError { param([string]$Msg) Write-Err $Msg; exit 1 }
|
|
|
|
function Test-Command { param([string]$Name) $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) }
|
|
|
|
function Get-RandomPassword {
|
|
param([int]$Length = 24)
|
|
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
|
$bytes = New-Object byte[] $Length
|
|
$rng.GetBytes($bytes)
|
|
-join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
|
|
}
|
|
|
|
function Get-RandomHex {
|
|
param([int]$Length = 32)
|
|
$bytes = New-Object byte[] ($Length / 2)
|
|
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
|
($bytes | ForEach-Object { $_.ToString("x2") }) -join ''
|
|
}
|
|
|
|
# ── Prerequisites ─────────────────────────────────────────────────────────────
|
|
function Test-Prerequisites {
|
|
$hasDocker = $false
|
|
$hasNode = $false
|
|
|
|
if (Test-Command "docker") {
|
|
docker info *>$null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$hasDocker = $true
|
|
$dockerVersion = (docker --version) -split "`n" | Select-Object -First 1
|
|
Write-Ok "Docker available ($dockerVersion)"
|
|
} else {
|
|
Write-Warn "Docker found but daemon is not running"
|
|
}
|
|
}
|
|
|
|
if (Test-Command "node") {
|
|
$nodeVersion = (node -v).TrimStart('v')
|
|
$nodeMajor = [int]($nodeVersion -split '\.')[0]
|
|
if ($nodeMajor -ge 22) {
|
|
$hasNode = $true
|
|
Write-Ok "Node.js v$nodeVersion available"
|
|
} else {
|
|
Write-Warn "Node.js v$nodeVersion found but v22+ required (LTS recommended)"
|
|
}
|
|
}
|
|
|
|
if (-not $hasDocker -and -not $hasNode) {
|
|
Stop-WithError "Either Docker or Node.js 22+ is required. Install one and retry."
|
|
}
|
|
|
|
# Auto-select deploy mode if not specified
|
|
if (-not $script:Mode) {
|
|
if ($hasDocker) {
|
|
$script:Mode = "docker"
|
|
Write-MC "Auto-selected Docker deployment (use -Mode local to override)"
|
|
} else {
|
|
$script:Mode = "local"
|
|
Write-MC "Auto-selected local deployment (Docker not available)"
|
|
}
|
|
}
|
|
|
|
# Validate chosen mode
|
|
if ($script:Mode -eq "docker" -and -not $hasDocker) {
|
|
Stop-WithError "Docker deployment requested but Docker is not available"
|
|
}
|
|
if ($script:Mode -eq "local" -and -not $hasNode) {
|
|
Stop-WithError "Local deployment requested but Node.js 22+ is not available"
|
|
}
|
|
if ($script:Mode -eq "local" -and -not (Test-Command "pnpm")) {
|
|
Write-MC "Installing pnpm via corepack..."
|
|
corepack enable
|
|
corepack prepare pnpm@latest --activate
|
|
Write-Ok "pnpm installed"
|
|
}
|
|
}
|
|
|
|
# ── Clone or update repo ─────────────────────────────────────────────────────
|
|
function Get-Source {
|
|
if (Test-Path (Join-Path $script:InstallDir ".git")) {
|
|
Write-MC "Updating existing installation at $($script:InstallDir)..."
|
|
Push-Location $script:InstallDir
|
|
try {
|
|
git fetch --tags
|
|
$latestTag = git describe --tags --abbrev=0 origin/main 2>$null
|
|
if ($latestTag) {
|
|
git checkout $latestTag
|
|
Write-Ok "Checked out $latestTag"
|
|
} else {
|
|
git pull origin main
|
|
Write-Ok "Updated to latest main"
|
|
}
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
} else {
|
|
Write-MC "Cloning Mission Control..."
|
|
if (-not (Test-Command "git")) {
|
|
Stop-WithError "git is required to clone the repository"
|
|
}
|
|
git clone --depth 1 $RepoUrl $script:InstallDir
|
|
Write-Ok "Cloned to $($script:InstallDir)"
|
|
}
|
|
}
|
|
|
|
# ── Generate .env ─────────────────────────────────────────────────────────────
|
|
function New-EnvFile {
|
|
$envPath = Join-Path $script:InstallDir ".env"
|
|
$examplePath = Join-Path $script:InstallDir ".env.example"
|
|
|
|
if (Test-Path $envPath) {
|
|
Write-MC "Existing .env found - keeping current configuration"
|
|
return
|
|
}
|
|
|
|
if (-not (Test-Path $examplePath)) {
|
|
Stop-WithError ".env.example not found at $examplePath"
|
|
}
|
|
|
|
Write-MC "Generating secure .env configuration..."
|
|
|
|
$authPass = Get-RandomPassword 24
|
|
$apiKey = Get-RandomHex 32
|
|
$authSecret = Get-RandomPassword 32
|
|
|
|
$content = Get-Content $examplePath -Raw
|
|
$content = $content -replace '(?m)^# AUTH_USER=.*', "AUTH_USER=admin"
|
|
$content = $content -replace '(?m)^# AUTH_PASS=.*', "AUTH_PASS=$authPass"
|
|
$content = $content -replace '(?m)^# API_KEY=.*', "API_KEY=$apiKey"
|
|
$content = $content -replace '(?m)^# AUTH_SECRET=.*', "AUTH_SECRET=$authSecret"
|
|
|
|
# Set port if non-default
|
|
if ($script:Port -ne 3000) {
|
|
$content = $content -replace '(?m)^# PORT=3000', "PORT=$($script:Port)"
|
|
}
|
|
|
|
$content | Set-Content $envPath -NoNewline
|
|
Write-Ok "Secure .env generated"
|
|
|
|
Write-Host ""
|
|
Write-Host " AUTH_USER: admin" -ForegroundColor Cyan
|
|
Write-Host " AUTH_PASS: $authPass" -ForegroundColor Cyan
|
|
Write-Host " API_KEY: $apiKey" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host " Save these credentials - they are not stored elsewhere." -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|
|
|
|
# ── Docker deployment ─────────────────────────────────────────────────────────
|
|
function Deploy-Docker {
|
|
Write-MC "Starting Docker deployment..."
|
|
|
|
Push-Location $script:InstallDir
|
|
try {
|
|
$env:MC_PORT = $script:Port
|
|
docker compose up -d --build
|
|
|
|
Write-MC "Waiting for Mission Control to become healthy..."
|
|
$retries = 30
|
|
while ($retries -gt 0) {
|
|
try {
|
|
$response = Invoke-WebRequest -Uri "http://localhost:$($script:Port)/login" -UseBasicParsing -TimeoutSec 2 -ErrorAction SilentlyContinue
|
|
if ($response.StatusCode -eq 200) { break }
|
|
} catch { }
|
|
Start-Sleep -Seconds 2
|
|
$retries--
|
|
}
|
|
|
|
if ($retries -eq 0) {
|
|
Write-Warn "Timeout waiting for health check - container may still be starting"
|
|
docker compose logs --tail 20
|
|
} else {
|
|
Write-Ok "Mission Control is running in Docker"
|
|
}
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
|
|
# ── Local deployment ──────────────────────────────────────────────────────────
|
|
function Deploy-Local {
|
|
Write-MC "Starting local deployment..."
|
|
|
|
Push-Location $script:InstallDir
|
|
try {
|
|
pnpm install --frozen-lockfile 2>$null
|
|
if ($LASTEXITCODE -ne 0) { pnpm install }
|
|
Write-Ok "Dependencies installed"
|
|
|
|
Write-MC "Building Mission Control..."
|
|
pnpm build
|
|
if ($LASTEXITCODE -ne 0) { Stop-WithError "Build failed" }
|
|
Write-Ok "Build complete"
|
|
|
|
# Copy static assets into standalone directory (required by Next.js standalone mode)
|
|
$standaloneDir = Join-Path $script:InstallDir ".next" | Join-Path -ChildPath "standalone"
|
|
$standaloneNextDir = Join-Path $standaloneDir ".next"
|
|
$sourceStatic = Join-Path $script:InstallDir ".next" | Join-Path -ChildPath "static"
|
|
$destStatic = Join-Path $standaloneNextDir "static"
|
|
$sourcePublic = Join-Path $script:InstallDir "public"
|
|
$destPublic = Join-Path $standaloneDir "public"
|
|
|
|
if (Test-Path $sourceStatic) {
|
|
if (Test-Path $destStatic) { Remove-Item $destStatic -Recurse -Force }
|
|
Copy-Item $sourceStatic $destStatic -Recurse
|
|
}
|
|
if (Test-Path $sourcePublic) {
|
|
if (Test-Path $destPublic) { Remove-Item $destPublic -Recurse -Force }
|
|
Copy-Item $sourcePublic $destPublic -Recurse
|
|
}
|
|
Write-Ok "Static assets copied to standalone directory"
|
|
|
|
Write-MC "Starting Mission Control..."
|
|
$env:PORT = $script:Port
|
|
$env:NODE_ENV = "production"
|
|
$env:HOSTNAME = "0.0.0.0"
|
|
$dataPath = Join-Path $script:InstallDir ".data"
|
|
$logPath = Join-Path $dataPath "mc.log"
|
|
$errLogPath = Join-Path $dataPath "mc-err.log"
|
|
$pidPath = Join-Path $dataPath "mc.pid"
|
|
|
|
$serverJs = Join-Path $standaloneDir "server.js"
|
|
$process = Start-Process -FilePath "cmd.exe" `
|
|
-ArgumentList "/c node `"$serverJs`" > `"$logPath`" 2> `"$errLogPath`"" `
|
|
-WorkingDirectory $script:InstallDir `
|
|
-WindowStyle Hidden `
|
|
-PassThru
|
|
$process.Id | Set-Content $pidPath
|
|
|
|
Start-Sleep -Seconds 3
|
|
if (-not $process.HasExited) {
|
|
Write-Ok "Mission Control running (PID $($process.Id))"
|
|
} else {
|
|
Write-Err "Failed to start. Check logs: $logPath"
|
|
exit 1
|
|
}
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
|
|
# ── OpenClaw fleet check ─────────────────────────────────────────────────────
|
|
function Test-OpenClaw {
|
|
if ($SkipOpenClaw) {
|
|
Write-MC "Skipping OpenClaw checks (-SkipOpenClaw)"
|
|
return
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-MC "=== OpenClaw Fleet Check ==="
|
|
|
|
if (Test-Command "openclaw") {
|
|
$ocVersion = try { openclaw --version 2>$null } catch { "unknown" }
|
|
Write-Ok "OpenClaw binary found: $ocVersion"
|
|
} elseif (Test-Command "clawdbot") {
|
|
$cbVersion = try { clawdbot --version 2>$null } catch { "unknown" }
|
|
Write-Ok "ClawdBot binary found: $cbVersion (legacy)"
|
|
Write-Warn "Consider upgrading to openclaw CLI"
|
|
} else {
|
|
Write-MC "OpenClaw CLI not found - install it to enable agent orchestration"
|
|
Write-MC " See: https://github.com/builderz-labs/openclaw"
|
|
return
|
|
}
|
|
|
|
# Check OpenClaw home directory
|
|
$ocHome = if ($env:OPENCLAW_HOME) { $env:OPENCLAW_HOME } else { Join-Path $HOME ".openclaw" }
|
|
if (Test-Path $ocHome) {
|
|
Write-Ok "OpenClaw home: $ocHome"
|
|
|
|
$ocConfig = Join-Path $ocHome "openclaw.json"
|
|
if (Test-Path $ocConfig) {
|
|
Write-Ok "Config found: $ocConfig"
|
|
} else {
|
|
Write-Warn "No openclaw.json found at $ocConfig"
|
|
Write-MC "Mission Control will create a default config on first gateway connection"
|
|
}
|
|
} else {
|
|
Write-MC "OpenClaw home not found at $ocHome"
|
|
Write-MC "Set OPENCLAW_HOME in .env to point to your OpenClaw state directory"
|
|
}
|
|
|
|
# Check gateway port
|
|
$gwHost = if ($env:OPENCLAW_GATEWAY_HOST) { $env:OPENCLAW_GATEWAY_HOST } else { "127.0.0.1" }
|
|
$gwPort = if ($env:OPENCLAW_GATEWAY_PORT) { [int]$env:OPENCLAW_GATEWAY_PORT } else { 18789 }
|
|
try {
|
|
$tcp = New-Object System.Net.Sockets.TcpClient
|
|
$tcp.Connect($gwHost, $gwPort)
|
|
$tcp.Close()
|
|
Write-Ok "Gateway reachable at ${gwHost}:${gwPort}"
|
|
} catch {
|
|
Write-MC "Gateway not reachable at ${gwHost}:${gwPort} (start it with: openclaw gateway start)"
|
|
}
|
|
}
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
function Main {
|
|
Write-Host ""
|
|
Write-Host " +======================================+" -ForegroundColor Magenta
|
|
Write-Host " | Mission Control Installer |" -ForegroundColor Magenta
|
|
Write-Host " | The mothership for your fleet |" -ForegroundColor Magenta
|
|
Write-Host " +======================================+" -ForegroundColor Magenta
|
|
Write-Host ""
|
|
|
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }
|
|
Write-Ok "Detected Windows/$arch"
|
|
|
|
Test-Prerequisites
|
|
|
|
# If running from within an existing clone, use current dir
|
|
$packageJson = Join-Path (Get-Location) "package.json"
|
|
if ((Test-Path $packageJson) -and (Select-String -Path $packageJson -Pattern '"mission-control"' -Quiet)) {
|
|
$script:InstallDir = (Get-Location).Path
|
|
Write-MC "Running from existing clone at $($script:InstallDir)"
|
|
} else {
|
|
Get-Source
|
|
}
|
|
|
|
# Ensure data directory exists
|
|
$dataDir = Join-Path $script:InstallDir ".data"
|
|
if (-not (Test-Path $dataDir)) {
|
|
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
|
|
}
|
|
|
|
New-EnvFile
|
|
|
|
switch ($Mode) {
|
|
"docker" { Deploy-Docker }
|
|
"local" { Deploy-Local }
|
|
default { Stop-WithError "Unknown deploy mode: $Mode" }
|
|
}
|
|
|
|
Test-OpenClaw
|
|
|
|
# ── Print summary ──
|
|
Write-Host ""
|
|
Write-Host " +======================================+" -ForegroundColor Green
|
|
Write-Host " | Installation Complete |" -ForegroundColor Green
|
|
Write-Host " +======================================+" -ForegroundColor Green
|
|
Write-Host ""
|
|
Write-MC "Dashboard: http://localhost:$Port"
|
|
Write-MC "Mode: $Mode"
|
|
Write-MC "Data: $(Join-Path $script:InstallDir '.data')"
|
|
Write-Host ""
|
|
Write-MC "Credentials are in: $(Join-Path $script:InstallDir '.env')"
|
|
Write-Host ""
|
|
|
|
if ($Mode -eq "docker") {
|
|
Write-MC "Manage:"
|
|
Write-MC " docker compose logs -f # view logs"
|
|
Write-MC " docker compose restart # restart"
|
|
Write-MC " docker compose down # stop"
|
|
} else {
|
|
$mcDataPath = Join-Path $script:InstallDir ".data"
|
|
$pidPath = Join-Path $mcDataPath "mc.pid"
|
|
$logPath = Join-Path $mcDataPath "mc.log"
|
|
Write-MC "Manage:"
|
|
Write-MC " Get-Content '$logPath' -Tail 50 # view logs"
|
|
Write-MC " Stop-Process -Id (Get-Content '$pidPath') # stop"
|
|
}
|
|
Write-Host ""
|
|
}
|
|
|
|
Main |