Public/Show-TaxonomyEditor.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Show-TaxonomyEditor { <# .SYNOPSIS Launch the Taxonomy Editor. .DESCRIPTION Starts the Taxonomy Editor and opens it in your default web browser. By default, runs in container mode (Docker): all dependencies are encapsulated in a Docker image, so nothing beyond Docker needs to be installed on the host. Falls back to legacy dev mode (Electron via npm run dev) if Docker is not available but Node.js is installed. Use -Dev to force legacy Electron dev mode even when Docker is available. .PARAMETER Port Port for the web server. Default: 7862. .PARAMETER DataPath Override the data directory path. By default, resolved via .aitriad.json or $env:AI_TRIAD_DATA_ROOT. .PARAMETER NoBrowser Start the server without opening a browser. .PARAMETER Pull Force-pull the latest Docker image even if one is already cached. .PARAMETER Detach Run the container in the background and return immediately. .PARAMETER Stop Stop a detached Taxonomy Editor container. .PARAMETER Status Show whether a Taxonomy Editor container is running. .PARAMETER Dev Force legacy Electron dev mode (npm run dev) instead of container mode. .EXAMPLE Show-TaxonomyEditor # Opens in browser via Docker container .EXAMPLE Show-TaxonomyEditor -Port 8080 -DataPath ~/research-data # Custom port and data directory .EXAMPLE Show-TaxonomyEditor -Detach # ... later ... Show-TaxonomyEditor -Stop .EXAMPLE Show-TaxonomyEditor -Dev # Legacy Electron desktop app (requires Node.js) #> [CmdletBinding(DefaultParameterSetName = 'Run')] param( [Parameter(ParameterSetName = 'Run')] [Parameter(ParameterSetName = 'Dev')] [int]$Port = 7862, [Parameter(ParameterSetName = 'Run')] [string]$DataPath, [Parameter(ParameterSetName = 'Run')] [Parameter(ParameterSetName = 'Dev')] [switch]$NoBrowser, [Parameter(ParameterSetName = 'Run')] [switch]$Pull, [Parameter(ParameterSetName = 'Run')] [switch]$Detach, [Parameter(ParameterSetName = 'Stop')] [switch]$Stop, [Parameter(ParameterSetName = 'Status')] [switch]$Status, [Parameter(ParameterSetName = 'Dev')] [switch]$Dev ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── Handle -Stop and -Status immediately ────────────────────────────────── if ($Stop) { Stop-TaxonomyContainer -Port $Port return } if ($Status) { Get-TaxonomyContainerStatus -Port $Port return } # ── Decide launch mode ──────────────────────────────────────────────────── $UseDocker = $false if ($Dev) { # Forced legacy mode $UseDocker = $false } elseif (Get-Command docker -ErrorAction SilentlyContinue) { # Docker available — try it try { $null = docker info 2>&1 if ($LASTEXITCODE -eq 0) { $UseDocker = $true } } catch { } } if (-not $UseDocker -and -not $Dev) { # Docker not available — check if we can fall back to dev mode if (Get-Command npm -ErrorAction SilentlyContinue) { Write-Info 'Docker not available. Falling back to Electron dev mode.' $Dev = $true } else { throw (New-ActionableError ` -Goal 'Launch Taxonomy Editor' ` -Problem 'Neither Docker nor Node.js/npm is available' ` -Location 'Show-TaxonomyEditor' ` -NextSteps @( 'Install Docker Desktop: https://www.docker.com/products/docker-desktop/' 'Or install Node.js: https://nodejs.org/' )) } } # ── Legacy Electron dev mode ────────────────────────────────────────────── if ($Dev) { Start-LegacyElectronMode -NoBrowser:$NoBrowser return } # ── Container mode ──────────────────────────────────────────────────────── Start-ContainerMode -Port $Port -DataPath $DataPath -NoBrowser:$NoBrowser ` -Pull:$Pull -Detach:$Detach } # ── Private: Legacy Electron mode ───────────────────────────────────────────── function Start-LegacyElectronMode { [CmdletBinding()] param([switch]$NoBrowser) $AppDir = Join-Path (Get-CodeRoot) 'taxonomy-editor' if (-not (Test-Path $AppDir)) { Write-Fail "App directory not found: $AppDir" return } # Check data $TaxDir = Get-TaxonomyDir $DataOk = Test-Path (Join-Path $TaxDir 'accelerationist.json') if (-not $DataOk) { Write-Warn "AI Triad data not found at: $TaxDir" $Choice = $Host.UI.PromptForChoice( 'Missing Data', 'Run Install-AITriadData to clone the data repository?', @('&Yes', '&No'), 0 ) if ($Choice -eq 0) { Install-AITriadData if (-not (Test-Path (Join-Path $TaxDir 'accelerationist.json'))) { Write-Fail 'Data installation did not complete. Cannot launch Taxonomy Editor.' return } } else { Write-Warn 'Launching without data — the app may not function correctly.' } } # Check npm if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { Write-Warn 'npm not found — Node.js is required for Electron dev mode.' $Choice = $Host.UI.PromptForChoice( 'Missing Dependency', 'Run Install-AIDependencies to install Node.js and other dependencies?', @('&Yes', '&No'), 0 ) if ($Choice -eq 0) { Install-AIDependencies -SkipPython } if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { Write-Fail 'npm still not found after install attempt. Cannot launch Taxonomy Editor.' return } } # Check node_modules $NodeModules = Join-Path $AppDir 'node_modules' if (-not (Test-Path $NodeModules)) { Write-Warn "Node modules not installed in taxonomy-editor/." $Choice = $Host.UI.PromptForChoice( 'Missing Node Modules', "Run 'npm install' in the taxonomy-editor directory?", @('&Yes', '&No'), 0 ) if ($Choice -eq 0) { Write-Step 'Installing Node modules' Push-Location $AppDir try { npm install if ($LASTEXITCODE -ne 0) { Write-Fail "npm install failed (exit code $LASTEXITCODE)." return } Write-OK 'Node modules installed.' } finally { Pop-Location } } else { Write-Warn "Proceeding without node_modules — 'npm run dev' will likely fail." } } # Launch Push-Location $AppDir try { npm run dev if ($LASTEXITCODE -ne 0) { Write-Fail "npm run dev exited with code $LASTEXITCODE." } } finally { Pop-Location } } # ── Private: Container mode ─────────────────────────────────────────────────── function Start-ContainerMode { [CmdletBinding()] param( [int]$Port, [string]$DataPath, [switch]$NoBrowser, [switch]$Pull, [switch]$Detach ) $ImageName = 'aitriad/taxonomy-editor:latest' $ContainerName = Get-ContainerName -Port $Port # ── Resolve data path ───────────────────────────────────────────────── if (-not $DataPath) { # Use the same resolution logic as the module if ($env:AI_TRIAD_DATA_ROOT) { $DataPath = $env:AI_TRIAD_DATA_ROOT } else { try { $DataPath = Resolve-DataPath '.' } catch { # Last resort: sibling directory $CodeRoot = Get-CodeRoot $DataPath = Join-Path (Split-Path $CodeRoot -Parent) 'ai-triad-data' } } } $DataPath = (Resolve-Path -Path $DataPath -ErrorAction SilentlyContinue)?.Path if (-not $DataPath -or -not (Test-Path $DataPath)) { Write-Warn "Data directory not found: $DataPath" Write-Info 'The editor will show the First Run dialog to set up data.' # Create the directory so Docker can mount it $DataPath = if ($env:AI_TRIAD_DATA_ROOT) { $env:AI_TRIAD_DATA_ROOT } else { Join-Path (Split-Path (Get-CodeRoot) -Parent) 'ai-triad-data' } $null = New-Item -ItemType Directory -Path $DataPath -Force -ErrorAction SilentlyContinue } Write-Info "Data directory: $DataPath" # ── Check for existing container on this port ───────────────────────── if (Test-TaxonomyContainerRunning -Port $Port) { Write-OK "Taxonomy Editor is already running at http://localhost:$Port" if (-not $NoBrowser) { Start-Process "http://localhost:$Port" } return } # ── Clean up stale container with same name ────────────────────────── $stale = docker ps -a --filter "name=$ContainerName" --format '{{.Names}}' 2>&1 if ($stale -eq $ContainerName) { docker rm $ContainerName 2>&1 | Out-Null } # ── Pull image if needed ────────────────────────────────────────────── if ($Pull -or -not (Test-DockerImageExists -ImageName $ImageName)) { Pull-TaxonomyEditorImage -ImageName $ImageName } # ── Build docker run arguments ──────────────────────────────────────── $envArgs = Get-ApiKeyEnvArgs # UID/GID for bind mount and tmpfs ownership $uid = $null $gid = $null if ($IsLinux -or $IsMacOS) { $uid = id -u 2>$null $gid = id -g 2>$null } $tmpfsUidOpt = if ($uid -and $gid) { ",uid=$uid,gid=$gid" } else { '' } $runArgs = @( 'run', '--rm' '--name', $ContainerName '-p', "${Port}:7862" '-v', "${DataPath}:/data" # Security hardening '--cap-drop', 'ALL' '--read-only' '--tmpfs', "/tmp:rw,noexec,nosuid,size=256m${tmpfsUidOpt}" '--tmpfs', "/app/.cache:rw,noexec,nosuid,size=512m${tmpfsUidOpt}" # Writable home for PowerShell config/cache (needed with --read-only) '--tmpfs', "/home/aitriad:rw,noexec,nosuid,size=64m${tmpfsUidOpt}" '-e', 'HOME=/home/aitriad' # Resource limits '--memory', '4g' '--cpus', '2' ) # UID/GID matching — prevent file permission mismatches on the bind mount if ($uid -and $gid) { $runArgs += '--user' $runArgs += "${uid}:${gid}" } $runArgs += $envArgs if ($Detach) { $runArgs += '-d' } $runArgs += $ImageName # ── Launch ──────────────────────────────────────────────────────────── if ($Detach) { Write-Step 'Starting Taxonomy Editor (detached)' $dockerOutput = docker @runArgs 2>&1 if ($LASTEXITCODE -ne 0) { $errorText = ($dockerOutput | Out-String).Trim() Write-Fail "Failed to start container (exit code $LASTEXITCODE)." if ($errorText -match 'port is already allocated|address already in use') { Write-Warn "Port $Port is already in use by another process." Write-Info "Fix: Stop whatever is using port ${Port}:" Write-Info " docker ps # find the container" Write-Info " docker stop <container-id> # stop it" Write-Info " lsof -i :$Port # or find non-Docker process" Write-Info "Or use a different port: Show-TaxonomyEditor -Port 8080" } elseif ($errorText -match 'is already in use by container') { Write-Warn "A container named '$ContainerName' already exists." Write-Info "Fix: Remove the stale container and retry:" Write-Info " docker rm $ContainerName" Write-Info " Show-TaxonomyEditor" } elseif ($errorText -match 'No such image') { Write-Warn 'Docker image not found locally.' Write-Info "Fix: Pull the image first:" Write-Info " Show-TaxonomyEditor -Pull" } else { Write-Info "Docker error: $errorText" Write-Info "Try: docker logs $ContainerName" } return } $ready = Wait-ForHealthEndpoint -Port $Port -TimeoutSeconds 30 if ($ready) { Test-ContainerVersionCompat -Port $Port if (-not $NoBrowser) { Start-Process "http://localhost:$Port" } } Write-OK "Taxonomy Editor running at http://localhost:$Port (detached)" Write-Info "Stop with: Show-TaxonomyEditor -Stop" } else { Write-Step 'Starting Taxonomy Editor' # Start the container detached, then block here until Ctrl+C $fgArgs = $runArgs.Clone() # Insert -d flag before the image name (last element) $fgArgs = @($fgArgs[0..($fgArgs.Count - 2)]) + @('-d') + @($fgArgs[-1]) $dockerOutput = docker @fgArgs 2>&1 if ($LASTEXITCODE -ne 0) { $errorText = ($dockerOutput | Out-String).Trim() Write-Fail "Failed to start container (exit code $LASTEXITCODE)." if ($errorText -match 'port is already allocated|address already in use') { Write-Warn "Port $Port is already in use by another process." Write-Info "Fix: Stop whatever is using port ${Port}:" Write-Info " docker ps # find the container" Write-Info " docker stop <container-id> # stop it" Write-Info " lsof -i :$Port # or find non-Docker process" Write-Info "Or use a different port: Show-TaxonomyEditor -Port 8080" } elseif ($errorText -match 'is already in use by container') { Write-Warn "A container named '$ContainerName' already exists." Write-Info "Fix: Remove the stale container and retry:" Write-Info " docker rm $ContainerName" Write-Info " Show-TaxonomyEditor" } elseif ($errorText -match 'No such image') { Write-Warn 'Docker image not found locally.' Write-Info "Fix: Pull the image first:" Write-Info " Show-TaxonomyEditor -Pull" } else { Write-Info "Docker error: $errorText" Write-Info "Try: docker logs $ContainerName" } return } # Wait for the health endpoint $ready = Wait-ForHealthEndpoint -Port $Port -TimeoutSeconds 30 if ($ready) { Test-ContainerVersionCompat -Port $Port if (-not $NoBrowser) { Start-Process "http://localhost:$Port" } Write-OK "Taxonomy Editor running at http://localhost:$Port" Write-Info 'Press Ctrl+C to stop.' } # Block until the user presses Ctrl+C try { while (Test-TaxonomyContainerRunning -Port $Port) { Start-Sleep -Seconds 2 } } catch { # Ctrl+C } finally { if (Test-TaxonomyContainerRunning -Port $Port) { Write-Step 'Stopping container...' docker stop $ContainerName 2>&1 | Out-Null } # Clean up the stopped container docker rm $ContainerName 2>&1 | Out-Null Write-OK 'Stopped.' } } } |