# PhotoAI installer for Windows — run by the PhotoAI app over ssh (or by hand in PowerShell): # # powershell -ExecutionPolicy Bypass -File install.ps1 check # powershell -ExecutionPolicy Bypass -File install.ps1 dirs C:\Users\me # powershell -ExecutionPolicy Bypass -File install.ps1 install PHOTOS_DIR=D:\Photos DRIVES_1=D:\ AI=ollama # powershell -ExecutionPolicy Bypass -File install.ps1 status # powershell -ExecutionPolicy Bypass -File install.ps1 prepare DOCKER=1 OLLAMA=1 install what is missing # powershell -ExecutionPolicy Bypass -File install.ps1 reboot restart the computer # powershell -ExecutionPolicy Bypass -File install.ps1 remote-off turn the ssh server off # prepare/reboot/remote-off need an administrator account (ssh sessions of administrators run elevated). # # install keys: PHOTOAI_DIR (%USERPROFILE%\photoai) PHOTOS_DIR DRIVES_1 DRIVES_2 PORT (8000) # AI = ollama | url | none OLLAMA_URL PHOTOAI_IMAGE # EXTRAS = edit,3d,video,music (NVIDIA): ComfyUI and ACE-Step built here, their models downloaded # Output lines the app reads: "##KEY value"; others are shown as they are. $ErrorActionPreference = "Continue" $ProgressPreference = "SilentlyContinue" [Console]::OutputEncoding = [Text.Encoding]::UTF8 $Site = "https://setup.photo.myphonetools.com" # served by the VPS (relay/vps/setup-site.sh) $SiteOld = "https://photo.myphonetools.com/setup" # ldu's home server: tried when the first one can't be reached function Fetch($path, $dest) { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 "$Site/$path" -OutFile $dest; return $true } catch {} try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 "$SiteOld/$path" -OutFile $dest; return $true } catch { return $false } } $Mode = if ($args.Count -gt 0) { $args[0] } else { "check" } $Rest = if ($args.Count -gt 1) { $args[1..($args.Count - 1)] } else { @() } function Say($k, $v) { Write-Output "##$k $v" } function Fail($m) { Say "ERROR" $m; exit 1 } [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 # Progress for the app, one line per file whenever its percent changes: # ##PROGRESS (the app shows a bar per name) $script:pct = @{} function Progress($name, [double]$done, [double]$total, [bool]$finished = $false) { $p = if ($finished) { 100 } elseif ($total -gt 0) { [math]::Min(99, [int][math]::Floor($done * 100 / $total)) } else { 0 } if (-not $script:pct.ContainsKey($name) -or $script:pct[$name] -ne $p) { $script:pct[$name] = $p Write-Output ("##PROGRESS {0} {1} {2:F0} {3:F0}" -f $name, $p, $done, $total) } } function Download($url, $dest, $name) { # like Invoke-WebRequest -OutFile, with ##PROGRESS; throws on failure $req = [Net.HttpWebRequest]::Create($url); $req.Timeout = 60000; $req.ReadWriteTimeout = 120000; $req.UserAgent = "photoai-setup" $res = $req.GetResponse(); $total = [double]$res.ContentLength $in = $res.GetResponseStream(); $out = [IO.File]::Create($dest) try { $buf = New-Object byte[] 1048576; $done = 0 while (($n = $in.Read($buf, 0, $buf.Length)) -gt 0) { $out.Write($buf, 0, $n); $done += $n; Progress $name $done $total } if ($total -gt 0 -and $done -ne $total) { throw "download incomplete" } Progress $name $done $done $true } finally { $out.Close(); $in.Close(); $res.Close() } } function Pull-Images { # docker compose pull with one bar per image (the sum of its layers) $tot = @{}; $cur = @{}; $fin = @{} $bar = { param($img) $d = 0; $t = 0 foreach ($k in @($tot.Keys)) { if ($k.StartsWith("$img|")) { $t += $tot[$k]; $d += [double]$cur[$k] } } if ($fin[$img]) { Progress $img $t $t $true } else { Progress $img $d $t } } docker compose --progress json pull 2>&1 | ForEach-Object { $l = "$_" if ($l -notmatch '^\{') { if ($l.Trim()) { " $l" }; return } $j = $l | ConvertFrom-Json if (-not $j.parent_id) { if ("$($j.id)".StartsWith("Image ")) { $img = $j.id.Substring(6); if ($j.status -eq "Done") { $fin[$img] = $true }; & $bar $img } return } $img = $j.parent_id.Substring(6); $k = "$img|$($j.id)" if ($j.total -gt 0) { $tot[$k] = [double]$j.total } if ($j.text -eq "Downloading" -and $null -ne $j.current) { $cur[$k] = [double]$j.current } elseif ($tot.ContainsKey($k) -and $j.text -match 'complete|Verifying|Extracting|exists') { $cur[$k] = $tot[$k] } & $bar $img } } function Ollama-Pull($m) { # through Ollama's API (the CLI only draws a terminal bar); sets $script:ollamaOk $script:ollamaOk = $false $req = [Net.HttpWebRequest]::Create("http://localhost:11434/api/pull"); $req.Method = "POST"; $req.ContentType = "application/json" $req.Timeout = 60000; $req.ReadWriteTimeout = 600000 $b = [Text.Encoding]::UTF8.GetBytes((@{ model = $m } | ConvertTo-Json -Compress)); $w = $req.GetRequestStream(); $w.Write($b, 0, $b.Length); $w.Close() $r = New-Object IO.StreamReader ($req.GetResponse().GetResponseStream()) $tot = @{}; $cur = @{} try { while ($null -ne ($l = $r.ReadLine())) { $j = $l | ConvertFrom-Json if ($j.error) { " $($j.error)"; continue } if ($j.digest -and $j.total) { $tot[$j.digest] = [double]$j.total; if ($j.completed) { $cur[$j.digest] = [double]$j.completed } } $t = 0; $d = 0; foreach ($k in @($tot.Keys)) { $t += $tot[$k]; $d += [double]$cur[$k] } if ($j.status -eq "success") { $script:ollamaOk = $true; Progress $m $t $t $true; " success" } elseif ($t -gt 0) { Progress $m $d $t } } } finally { $r.Close() } } # Docker Desktop's command-line tools, when ssh's PATH doesn't have them $dockerBin = "$env:ProgramFiles\Docker\Docker\resources\bin" if ((Test-Path $dockerBin) -and -not ($env:Path -split ";" -contains $dockerBin)) { $env:Path += ";$dockerBin" } $ollamaBin = "$env:LOCALAPPDATA\Programs\Ollama" if (Test-Path $ollamaBin) { $env:Path += ";$ollamaBin" } # Docker Desktop keeps registry passwords in the Windows credential store, which an ssh session # can't open ("A specified logon session does not exist") — even public images fail. function Use-PlainDockerConfig { $cfg = Join-Path $env:USERPROFILE ".photoai-docker" New-Item -ItemType Directory -Force -Path $cfg | Out-Null Set-Content -Path (Join-Path $cfg "config.json") -Value "{}" -Encoding ascii $env:DOCKER_CONFIG = $cfg } function Docker-Access { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { return "missing" } docker info *> $null if ($LASTEXITCODE -eq 0) { return "ok" } return "stopped" } function Lan-IPs { Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notlike "127.*" -and $_.IPAddress -notlike "169.254.*" -and $_.InterfaceAlias -notmatch "vEthernet|WSL|Loopback|Docker" } | ForEach-Object { $_.IPAddress } } function Do-Check { Say "OS" "Windows" $os = Get-CimInstance Win32_OperatingSystem Say "OS_NAME" $os.Caption Say "RAM_GB" ([math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)) $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 Say "CPU" ($cpu.Name.Trim()) Say "CPU_CORES" ((Get-CimInstance Win32_Processor | Measure-Object NumberOfLogicalProcessors -Sum).Sum) $gpu = $null if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { Say "GPU" (nvidia-smi --query-gpu=name --format=csv,noheader | Select-Object -First 1) Say "GPU_KIND" "nvidia" $mib = (nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits | Select-Object -First 1) if ($mib -match '^\s*\d+') { Say "GPU_VRAM_GB" ([math]::Round([int]$mib.Trim() / 1024)) } } else { $v = Get-CimInstance Win32_VideoController | Where-Object { $_.Name -match "Radeon RX|Radeon PRO" } | Select-Object -First 1 if (-not $v) { $v = Get-CimInstance Win32_VideoController | Select-Object -First 1 } Say "GPU" $v.Name if ($v.Name -match "Radeon RX|Radeon PRO") { Say "GPU_KIND" "amd" # the real memory size (AdapterRAM stops at 4 GB) $key = "HKLM:\SYSTEM\ControlSet001\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}" $mem = Get-ChildItem $key -ErrorAction SilentlyContinue | ForEach-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue)."HardwareInformation.qwMemorySize" } | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1 if ($mem) { Say "GPU_VRAM_GB" ([math]::Round([double]$mem / 1GB)) } } else { Say "GPU_KIND" "none" } } Say "ARCH" $env:PROCESSOR_ARCHITECTURE Say "HOME" $env:USERPROFILE Say "USER" $env:USERNAME Say "ADMIN" ($(if (Is-Admin) { "yes" } else { "no" })) $acc = Docker-Access Say "DOCKER" $acc if ($acc -eq "ok") { Say "DOCKER_VERSION" (docker version --format "{{.Server.Version}}" 2>$null) docker compose version *> $null Say "COMPOSE" ($(if ($LASTEXITCODE -eq 0) { "yes" } else { "no" })) } try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 http://localhost:11434/api/tags | Out-Null; Say "OLLAMA" "running" } catch { Say "OLLAMA" ($(if (Get-Command ollama -ErrorAction SilentlyContinue) { "installed" } else { "none" })) } foreach ($d in @("$env:USERPROFILE\Pictures", "$env:USERPROFILE\OneDrive\Pictures")) { if (Test-Path $d) { Say "PLACE" $d } } Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Free -ne $null } | ForEach-Object { Say "PLACE" ($_.Root) } foreach ($ip in Lan-IPs) { Say "IP" $ip } if (Test-Path "$env:USERPROFILE\photoai\docker-compose.yml") { Say "EXISTING" "$env:USERPROFILE\photoai" } $drive = Get-PSDrive ($env:USERPROFILE.Substring(0, 1)) Say "FREE_GB" ([math]::Round($drive.Free / 1GB)) Say "END" "check" } function Do-Dirs($p) { if (-not $p) { $p = $env:USERPROFILE } if (-not (Test-Path -LiteralPath $p -PathType Container)) { Fail "There is no folder $p" } Say "PATH" ((Resolve-Path -LiteralPath $p).ProviderPath) Get-ChildItem -LiteralPath $p -Directory -Force -ErrorAction SilentlyContinue | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::Hidden) -and -not ($_.Attributes -band [IO.FileAttributes]::System) -and $_.Name -notmatch '^[.$@]' } | Sort-Object Name | ForEach-Object { Say "DIR" $_.Name } Say "END" "dirs" } function Do-Install($kvs) { $s = @{ PHOTOAI_DIR = "$env:USERPROFILE\photoai"; PHOTOS_DIR = ""; DRIVES_1 = ""; DRIVES_2 = ""; PORT = "8000"; AI = "none"; OLLAMA_URL = ""; PHOTOAI_IMAGE = ""; EXTRAS = "" } foreach ($kv in $kvs) { $i = $kv.IndexOf("=") if ($i -lt 1) { Fail "Bad setting $kv" } $k = $kv.Substring(0, $i); $v = $kv.Substring($i + 1) if (-not $s.ContainsKey($k)) { Fail "Unknown setting $k" } $s[$k] = $v } if ($s.PORT -notmatch '^\d+$') { Fail "The port must be a number" } Say "STEP" "Checking Docker" switch (Docker-Access) { "missing" { Fail "Docker isn't installed on this computer — install Docker Desktop first (step 1 of the guide)" } "stopped" { Fail "Docker Desktop isn't running — start it on the computer (signed in as this user), wait for `"Engine running`", and try again" } } Use-PlainDockerConfig docker compose version *> $null if ($LASTEXITCODE -ne 0) { Fail "Docker Compose is missing — update Docker Desktop" } if ($s.PHOTOS_DIR -and -not (Test-Path -LiteralPath $s.PHOTOS_DIR)) { New-Item -ItemType Directory -Force -Path $s.PHOTOS_DIR | Out-Null } Say "STEP" "Downloading docker-compose.yml into $($s.PHOTOAI_DIR)" New-Item -ItemType Directory -Force -Path $s.PHOTOAI_DIR | Out-Null Set-Location $s.PHOTOAI_DIR if (-not (Fetch "docker-compose.yml" "docker-compose.yml")) { Fail "Couldn't download docker-compose.yml from $Site or $SiteOld" } Say "STEP" "Writing the settings (.env)" if (Test-Path .env) { Copy-Item .env .env.bak -Force } $lines = @("# Written by the PhotoAI app on $(Get-Date)") foreach ($k in "PHOTOS_DIR", "DRIVES_1", "DRIVES_2") { if ($s[$k]) { $lines += "$k=$($s[$k])" } } $lines += "PORT=$($s.PORT)" $lines += "LAN_IP=$(Lan-IPs | Select-Object -First 1)" $gw = Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1 if ($gw) { $lines += "LAN_GATEWAY=$($gw.NextHop)" } switch ($s.AI) { "ollama" { $lines += "OLLAMA_URL=http://host.docker.internal:11434" } "url" { if ($s.OLLAMA_URL) { $lines += "OLLAMA_URL=$($s.OLLAMA_URL)" } } } if ($s.PHOTOAI_IMAGE) { $lines += "PHOTOAI_IMAGE=$($s.PHOTOAI_IMAGE)" } $extras = @($s.EXTRAS -split "," | Where-Object { $_ }) $sets = @($extras | Where-Object { $_ -in "edit", "3d", "video" }) $profiles = @() if ($sets.Count) { $profiles += "extras"; $lines += "EDIT_URL=http://comfyui:8188" } if ($extras -contains "music") { $profiles += "music"; $lines += "MUSIC_URL=http://acestep:8000" } if ($extras.Count -and $s.AI -eq "ollama") { $lines += "SHARED_GPU=1"; $lines += "OLLAMA_KEEP_ALIVE=10m" } if ($profiles.Count) { $lines += "COMPOSE_PROFILES=$($profiles -join ',')"; $env:COMPOSE_PROFILES = ($profiles -join ",") } Set-Content -Path .env -Value $lines -Encoding ascii if ($extras.Count) { Say "STEP" "Downloading the extras' build files" foreach ($f in "comfyui/Dockerfile", "comfyui/get_models.py", "comfyui/photoai_nodes.py", "acestep/Dockerfile", "acestep/proxy.py", "acestep/get_music_models.py") { $dest = Join-Path "extras" $f New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null if (-not (Fetch "extras/$f" $dest)) { Fail "Couldn't download extras/$f from $Site or $SiteOld" } } $log = Join-Path $s.PHOTOAI_DIR "extras-install.log" if ($sets.Count) { Say "STEP" "Building ComfyUI for the extras (10–20 minutes the first time)" docker compose build comfyui *> $log if ($LASTEXITCODE -ne 0) { Get-Content $log -Tail 15 | ForEach-Object { " $_" }; Fail "Building ComfyUI failed (see $log)" } docker compose run --rm --no-deps comfyui python /opt/get_models.py @sets 2>&1 | Tee-Object -FilePath $log | Where-Object { $_ -match '^##(STEP|NOTE|ERROR|PROGRESS)' } if (Select-String -Path $log -Pattern '^##ERROR' -Quiet) { exit 1 } if (-not (Select-String -Path $log -Pattern 'extra models are ready' -Quiet)) { Fail "Downloading the extras' models stopped (see $log)" } } if ($extras -contains "music") { Say "STEP" "Building ACE-Step for music (10–20 minutes the first time)" docker compose build acestep *> $log if ($LASTEXITCODE -ne 0) { Get-Content $log -Tail 15 | ForEach-Object { " $_" }; Fail "Building ACE-Step failed (see $log)" } docker compose run --rm --no-deps acestep uv run --no-sync python /app/get_music_models.py 2>&1 | Tee-Object -FilePath $log | Where-Object { $_ -match '^##' } if (Select-String -Path $log -Pattern '^##ERROR' -Quiet) { exit 1 } if (-not (Select-String -Path $log -Pattern 'music models are ready' -Quiet)) { Fail "Downloading the music models stopped (see $log)" } } } Say "STEP" "Downloading PhotoAI (about 1.5 GB the first time)" docker compose --progress json version *> $null if ($LASTEXITCODE -eq 0) { Pull-Images } else { docker compose pull 2>&1 | ForEach-Object { " $_" } } Say "STEP" "Starting PhotoAI" docker compose up -d 2>&1 | ForEach-Object { " $_" } $running = docker compose ps --status running 2>$null | Select-String "app" if (-not $running) { Fail "PhotoAI didn't start — see: docker compose logs app" } if ($s.AI -eq "ollama" -and (Get-Command ollama -ErrorAction SilentlyContinue)) { foreach ($m in "qwen2.5vl:7b", "qwen3:1.7b", "nomic-embed-text") { Say "STEP" "Downloading the AI model $m" try { Ollama-Pull $m } catch { " $($_.Exception.Message)" } if (-not $script:ollamaOk) { ollama pull $m 2>&1 | Select-String "success|error" | ForEach-Object { " $_" } } } } Say "STEP" "Waiting for PhotoAI to answer" $ok = $false for ($i = 0; $i -lt 90; $i++) { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 "http://localhost:$($s.PORT)/api/auth/status" | Out-Null; $ok = $true; break } catch { Start-Sleep 2 } } if (-not $ok) { Fail "PhotoAI started but doesn't answer on port $($s.PORT) — see: docker compose logs app" } # let phones on the home network reach it try { New-NetFirewallRule -DisplayName "PhotoAI" -Direction Inbound -Protocol TCP -LocalPort $s.PORT -Action Allow -Profile Private -ErrorAction Stop | Out-Null # direct connections from the internet come in through the router on 8443 New-NetFirewallRule -DisplayName "PhotoAI direct" -Direction Inbound -Protocol TCP -LocalPort 8443 -Action Allow -Profile Any -ErrorAction Stop | Out-Null } catch { Say "NOTE" "Windows may ask to let Docker through the firewall — allow it on private networks" } foreach ($ip in Lan-IPs) { Say "IP" $ip } Say "PORT" $s.PORT Say "DONE" "PhotoAI is running in $($s.PHOTOAI_DIR)" } function Is-Admin { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) } # A program with a window must start in the signed-in user's desktop session, not in this ssh session function Start-InDesktop($name, $exe) { schtasks /create /f /tn $name /tr "`"$exe`"" /sc onlogon /ru $env:USERNAME /it /rl highest *> $null schtasks /run /tn $name *> $null } function Wait-For($seconds, [scriptblock]$test) { for ($i = 0; $i -lt $seconds; $i += 3) { if (& $test) { return $true }; Start-Sleep 3 } return $false } function Do-Prepare($kvs) { if (-not (Is-Admin)) { Fail "Installing needs an administrator account on this computer — sign in to the app with one" } $wantDocker = $kvs -contains "DOCKER=1"; $wantOllama = $kvs -contains "OLLAMA=1" if ($wantDocker) { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Say "STEP" "Checking WSL (Docker Desktop runs on it)" wsl.exe --status *> $null if ($LASTEXITCODE -ne 0) { Say "STEP" "Installing WSL" wsl.exe --install --no-distribution 2>&1 | ForEach-Object { " $_" } Say "NEED_REBOOT" "Windows must restart to finish installing WSL — then Docker Desktop is installed" Say "END" "prepare"; return } Say "STEP" "Downloading Docker Desktop (about 600 MB)" $inst = Join-Path $env:TEMP "DockerDesktopInstaller.exe" try { Download "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe" $inst "Docker-Desktop" } catch { Fail "Couldn't download Docker Desktop" } Say "STEP" "Installing Docker Desktop (a few minutes)" $p = Start-Process -FilePath $inst -ArgumentList "install", "--quiet", "--accept-license", "--backend=wsl-2" -Wait -PassThru Remove-Item $inst -ErrorAction SilentlyContinue if ($p.ExitCode -ne 0) { Fail "Docker Desktop's installer stopped (code $($p.ExitCode))" } net localgroup docker-users $env:USERNAME /add *> $null if (Test-Path $dockerBin) { $env:Path += ";$dockerBin" } } if ((Docker-Access) -ne "ok") { Say "STEP" "Starting Docker Desktop" Start-InDesktop "PhotoAI Docker Desktop" "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe" if (-not (Wait-For 300 { (Docker-Access) -eq "ok" })) { Say "NEED_LOGIN" "Docker Desktop didn't start — sign in to Windows on the computer as $env:USERNAME (it starts by itself), then try again" Say "END" "prepare"; return } } Say "NOTE" "Docker is ready" } if ($wantOllama) { $up = { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 http://localhost:11434/api/tags | Out-Null; $true } catch { $false } } if (-not (& $up)) { $app = "$env:LOCALAPPDATA\Programs\Ollama\ollama app.exe" if (-not (Test-Path $app)) { Say "STEP" "Downloading Ollama" $inst = Join-Path $env:TEMP "OllamaSetup.exe" try { Download "https://ollama.com/download/OllamaSetup.exe" $inst "Ollama" } catch { Fail "Couldn't download Ollama" } Say "STEP" "Installing Ollama" Start-Process -FilePath $inst -ArgumentList "/VERYSILENT", "/NORESTART", "/SUPPRESSMSGBOXES" -Wait Remove-Item $inst -ErrorAction SilentlyContinue } Say "STEP" "Starting Ollama" Start-InDesktop "PhotoAI Ollama" $app if (-not (Wait-For 90 $up)) { Say "NOTE" "Ollama didn't start yet — sign in to Windows on the computer once" } $env:Path += ";$env:LOCALAPPDATA\Programs\Ollama" } } Say "DONE" "prepare" } function Do-Reboot { if (-not (Is-Admin)) { Fail "Restarting needs an administrator account" } shutdown.exe /r /t 15 /c "PhotoAI setup: restarting to finish installing" | Out-Null Say "DONE" "Restarting in 15 seconds" } function Do-RemoteOff { if (-not (Is-Admin)) { Fail "Turning remote login off needs an administrator account" } # as SYSTEM, outside this session (stopping sshd ends it) schtasks /create /f /tn "PhotoAI remote login off" /ru SYSTEM /sc once /st 23:59 ` /tr "powershell -NoProfile -Command Start-Sleep 3; Set-Service sshd -StartupType Disabled; Stop-Service sshd -Force; schtasks /delete /f /tn 'PhotoAI remote login off'" *> $null schtasks /run /tn "PhotoAI remote login off" *> $null Say "DONE" "Remote login turns off in a few seconds" } function Do-Status { Say "DOCKER" (Docker-Access) Use-PlainDockerConfig if (Test-Path "$env:USERPROFILE\photoai\docker-compose.yml") { Set-Location "$env:USERPROFILE\photoai" docker compose ps --format "{{.Service}} {{.State}}" 2>$null | ForEach-Object { Say "SERVICE" $_ } } Say "END" "status" } switch ($Mode) { "check" { Do-Check } "dirs" { Do-Dirs ($Rest -join " ") } "install" { Do-Install $Rest } "status" { Do-Status } "prepare" { Do-Prepare $Rest } "reboot" { Do-Reboot } "remote-off" { Do-RemoteOff } default { Fail "Unknown mode $Mode (check, dirs, install, status, prepare, reboot, remote-off)" } }