# ============================================================================= # PTM Clearwork - one-command installer / manager (Windows, PowerShell) # # irm https://cleanwork.ptmsoftware.live/install.ps1 | iex # install & start # & ([scriptblock]::Create((irm https://cleanwork.ptmsoftware.live/install.ps1))) stop # # Or download install.ps1 and run: .\install.ps1 [start|stop|update|logs|status|uninstall] # # .\install.ps1 uninstall remove containers + images, ASK before deleting data # .\install.ps1 uninstall -Purge remove EVERYTHING incl. all data, no questions asked # # It owns a small compose file in %USERPROFILE%\.ptm-clearwork so you never # hand-edit one. Public images. To activate, enter the setup code PTM emails # after your purchase or card-verified trial in the dashboard. # ============================================================================= param([string]$Command = 'start', [switch]$Purge) # NOT 'Stop': Windows PowerShell wraps every stderr line a native exe (docker, # docker compose) writes into a terminating error under 'Stop' - regardless of # exit code and even when that stream is redirected to $null. Docker routinely # writes expected/benign output to stderr (e.g. "no such volume" on first run, # or pull/up-d progress), which would otherwise kill this script before the # explicit $LASTEXITCODE checks below ever run. Those checks are the real # failure detection here; this preference only needs to catch pure PowerShell # cmdlet errors (New-Item, Set-Content, etc.), which don't have this quirk. $ErrorActionPreference = 'Continue' # ---- Published image names (PUBLIC). Replace with your real registry refs. -- $ServerImage = if ($env:PTM_SERVER_IMAGE) { $env:PTM_SERVER_IMAGE } else { 'ptmtech/ptm-clearwork-server:1.9.0' } # x-release-please-version $MongoImage = if ($env:PTM_MONGO_IMAGE) { $env:PTM_MONGO_IMAGE } else { 'ptmtech/ptm-mongo:1.9.0' } # x-release-please-version # Where to start a trial or buy a plan (shown in the final message). The # server has PTM's portal built in; nothing is written to its settings. $PortalUrl = if ($env:LICENSING_PORTAL_URL) { $env:LICENSING_PORTAL_URL } else { 'https://cleanwork.ptmsoftware.live/licence/' } $PtmHome = if ($env:PTM_HOME) { $env:PTM_HOME } else { Join-Path $HOME '.ptm-clearwork' } $Project = 'ptm-clearwork' # BIND_ADDRESS=127.0.0.1 (default) means only this computer can open the # dashboard. BIND_ADDRESS=0.0.0.0 opens it to every computer on the network # (plain HTTP: fine for a LAN trial, but employee agents need HTTPS - see the # setup guide). Set either as an environment variable; start persists it. $HttpPortGiven = [bool]$env:HTTP_PORT $BindAddressGiven = [bool]$env:BIND_ADDRESS $HttpPort = if ($env:HTTP_PORT) { $env:HTTP_PORT } else { '3124' } $BindAddress = if ($env:BIND_ADDRESS) { $env:BIND_ADDRESS } else { '127.0.0.1' } $ComposeFile = Join-Path $PtmHome 'compose.yaml' $EnvFile = Join-Path $PtmHome '.env' function Say($m) { Write-Host "> $m" -ForegroundColor Magenta } function Ok($m) { Write-Host "OK $m" -ForegroundColor Green } function Warn($m) { Write-Host "! $m" -ForegroundColor Yellow } function Die($m) { Write-Host "x $m" -ForegroundColor Red; exit 1 } # Keep Docker's original output and add a plain-language cause and recovery step. # Do not use ErrorActionPreference=Stop for native commands (see above). function Get-DockerIssue([string]$Detail) { switch -Regex ($Detail) { 'pull access denied|unauthorized|authentication required|manifest unknown|not found: manifest|repository does not exist' { return 'The Docker image is unavailable or access was denied by the registry. Check the image name/tag and registry credentials, then try again.' } 'permission denied|access is denied|access denied|operation not permitted' { return 'Docker access was denied. Allow your Windows account to use Docker Desktop, then sign out and back in if permissions changed.' } 'address already in use|port is already allocated|ports are not available|only one usage of each socket address' { return "The dashboard port is unavailable. Stop the application using it, or choose a free HTTP_PORT in '$EnvFile', then run the same command again." } 'no space left on device|not enough space|disk full' { return 'Docker has run out of disk space. Free space on the host and in Docker Desktop, then run the same command again. Do not delete the PTM data volumes.' } 'x509:|certificate|tls handshake' { return 'Docker could not establish a secure connection. Check your system clock and Docker proxy/certificate settings, then try again.' } 'no matching manifest|does not match the specified platform|image operating system.*cannot be used' { return 'The Docker image does not support the selected platform. Use Linux containers in Docker Desktop and a supported processor architecture.' } 'toomanyrequests|too many requests|pull rate limit' { return 'The Docker registry download limit was reached. Sign in with docker login or wait for the limit to reset, then try again.' } 'context .*not found|unable to resolve docker endpoint|protocol not available' { return 'The Docker connection is not configured correctly. Check docker context ls and your DOCKER_HOST/DOCKER_CONTEXT settings, then try again.' } 'cannot connect to the docker daemon|is the docker daemon running|error during connect|pipe.*(not find|not found)' { return 'Docker is not running or its configured engine cannot be reached. Start Docker Desktop, wait for "Engine running", and check docker context ls before trying again.' } 'no such host|could not resolve|network is unreachable|i/o timeout|context deadline exceeded|connection timed out|proxyconnect|connection refused|failed to connect|cannot connect' { return 'Docker could not reach the registry or configured engine. Check your internet connection, DNS, VPN and proxy settings, then try again.' } } return $null } function Fail-Docker([string]$Failure, [string]$Detail) { $issue = Get-DockerIssue $Detail if ($issue) { Die "$Failure $issue" } Die "$Failure See the Docker error above. Check Docker Desktop and run .\install.ps1 logs for server startup errors." } function Invoke-DockerCommand([string[]]$DockerArgs) { $script:DockerOutput = @() docker @DockerArgs 2>&1 | Tee-Object -Variable captured | ForEach-Object { Write-Host "$_" } $script:DockerExitCode = $LASTEXITCODE $script:DockerOutput = $captured -join "`n" } function Invoke-Compose { if (-not (Test-Path -LiteralPath $ComposeFile) -or -not (Test-Path -LiteralPath $EnvFile)) { Die 'Installation settings are missing. Run: .\install.ps1 start' } # Log following is unbounded; stream it without accumulating a transcript. if ($args[0] -eq 'logs') { docker compose -p $Project -f $ComposeFile --env-file $EnvFile @args if ($LASTEXITCODE -ne 0) { Die 'Could not read server logs. Check that Docker is running and this account can access it; see the Docker error above.' } return } Invoke-DockerCommand (@('compose', '-p', $Project, '-f', $ComposeFile, '--env-file', $EnvFile) + $args) if ($DockerExitCode -ne 0) { Fail-Docker "Docker Compose '$($args[0])' failed (exit $DockerExitCode)." $DockerOutput } } # Docker Desktop's own executable. Per-user installs live in # %LOCALAPPDATA%\Programs\DockerDesktop, machine-wide ones in Program Files; # either way the docker CLI sits three levels below it in resources\bin. function Find-DockerDesktop { $candidates = @() $cli = (Get-Command docker -ErrorAction SilentlyContinue).Source if ($cli) { try { $candidates += Join-Path (Split-Path (Split-Path (Split-Path $cli))) 'Docker Desktop.exe' } catch { } } $candidates += Join-Path $env:LOCALAPPDATA 'Programs\DockerDesktop\Docker Desktop.exe' $candidates += Join-Path $env:ProgramFiles 'Docker\Docker\Docker Desktop.exe' $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 } # The image download is the one step that needs the internet, and home or office # networks drop a DNS lookup now and then ("no such host"). Retry a couple of # times before giving up, and say plainly what to check - the generic compose # failure message points at server logs, which cannot explain a download. function Pull-Images { # --quiet: captured output has no terminal, so Docker would print every # progress tick as a new line. Errors are still captured for Fail-Docker. Say 'The first download is a few hundred MB and can take a few minutes...' for ($attempt = 1; $attempt -le 3; $attempt++) { Invoke-DockerCommand @('compose', '-p', $Project, '-f', $ComposeFile, '--env-file', $EnvFile, 'pull', '--quiet') if ($DockerExitCode -eq 0) { return } # Only network/unknown failures benefit from an immediate retry. if ($DockerOutput -match 'permission denied|access.*denied|unauthorized|authentication required|manifest unknown|repository does not exist|not found: manifest|no matching manifest|no space left|certificate|x509:|toomanyrequests|too many requests|pull rate limit|cannot connect to the docker daemon|error during connect') { Fail-Docker 'Could not download the Docker images.' $DockerOutput } if ($attempt -lt 3) { Warn "Download failed - retrying in 10 seconds (attempt $($attempt + 1) of 3)..." Start-Sleep -Seconds 10 } } Fail-Docker 'Could not download the Docker images after 3 attempts.' $DockerOutput } # Docker is missing entirely. A buyer who pasted the one-line command may never # have heard of Docker, so explain what it is, check what Docker Desktop needs, # offer to install it with winget when that is available, and always end with # the exact next step. This never continues on its own: Docker Desktop needs a # first launch (licence terms, WSL 2 setup) that only the user can do. function Install-DockerGuidance { $desktop = Find-DockerDesktop if ($desktop) { Die 'Docker Desktop is installed, but this PowerShell window does not see it yet. Open a NEW PowerShell window and paste the same command again. If it still fails, open Docker Desktop from the Start menu once, wait for "Engine running", then retry.' } Warn 'Docker is not installed. PTM Clearwork runs inside Docker Desktop, a free download from Docker (large businesses need a Docker subscription; see Docker''s terms).' if (-not [Environment]::Is64BitOperatingSystem) { Die 'Docker Desktop needs 64-bit Windows and this computer runs 32-bit Windows, so it cannot host the server. Use another always-on computer (any Linux machine works too).' } $os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue if ($os) { $build = 0 try { $build = [int]$os.BuildNumber } catch { } if ($build -gt 0 -and $build -lt 19044) { Die "Docker Desktop needs Windows 10 version 21H2 (build 19044) or newer; this computer is on build $build. Update Windows first, then paste the same command again." } $ramGb = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1) if ($ramGb -lt 4) { Warn "This computer has $ramGb GB of memory; Docker Desktop needs at least 4 GB. It may be slow or fail to start." } } $wslReady = $false if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { $null = wsl.exe --status 2>&1 $wslReady = ($LASTEXITCODE -eq 0) } Write-Host '' Write-Host 'How to install Docker Desktop (about 10 minutes):' -ForegroundColor Cyan $step = 1 if (-not $wslReady) { Write-Host " $($step). Open PowerShell as Administrator and run: wsl --install" Write-Host ' Restart Windows when it asks. This turns on WSL 2, which Docker Desktop needs.' $step++ } $installed = $false if ($wslReady -and (Get-Command winget -ErrorAction SilentlyContinue)) { $answer = 'n' try { $answer = Read-Host 'Install Docker Desktop now with winget? Windows will ask for permission [Y/n]' } catch { $answer = 'n' } if ($answer -eq '' -or $answer -match '^[Yy]') { Say 'Installing Docker Desktop with winget (downloads about 600 MB)...' winget install -e --id Docker.DockerDesktop --accept-source-agreements --accept-package-agreements if ($LASTEXITCODE -eq 0) { $installed = $true; Ok 'Docker Desktop is installed.' } else { Warn "winget could not install Docker Desktop (exit $LASTEXITCODE). Install it manually with the steps below." } } } if (-not $installed) { Write-Host " $($step). Download Docker Desktop: https://docs.docker.com/desktop/setup/install/windows-install/" Write-Host ' Run the installer and keep "Use WSL 2" selected.' $step++ } Write-Host " $($step). Open Docker Desktop from the Start menu, accept its terms and wait until it shows ""Engine running""." $step++ Write-Host " $($step). Open a NEW PowerShell window and paste the same command again. It continues from here." Write-Host '' Write-Host 'Prefer not to run Docker Desktop on this computer? Any always-on Linux machine works; its installer sets Docker up for you.' exit 1 } function Require-Docker { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Install-DockerGuidance } $dockerInfo = docker info 2>&1 if ($LASTEXITCODE -ne 0) { # Permission, TLS, context and network failures are not a stopped engine. $detail = $dockerInfo -join "`n" if ($detail -match 'permission denied|access.*denied|operation not permitted|x509:|certificate|tls handshake|context .*not found|unable to resolve docker endpoint|protocol not available|no such host|i/o timeout|network is unreachable') { $dockerInfo | ForEach-Object { Write-Host "$_" } Fail-Docker 'Cannot access Docker.' $detail } # Installed but stopped: start Docker Desktop ourselves instead of making # the user do it and paste the command again. $desktop = Find-DockerDesktop if (-not $desktop) { $dockerInfo | ForEach-Object { Write-Host "$_" } Fail-Docker 'Docker is not running or its configured engine cannot be reached.' $detail } Say 'Docker is not running or cannot be reached - starting Docker Desktop (this can take a minute)...' try { Start-Process -FilePath $desktop -WindowStyle Hidden -ErrorAction Stop } catch { Die "Docker is not running. Could not start Docker Desktop: $($_.Exception.Message). Open Docker Desktop manually and try again." } $deadline = (Get-Date).AddMinutes(3) do { Start-Sleep -Seconds 3 Write-Host '.' -NoNewline $dockerInfo = docker info 2>&1 } while ($LASTEXITCODE -ne 0 -and (Get-Date) -lt $deadline) Write-Host '' if ($LASTEXITCODE -ne 0) { $dockerInfo | ForEach-Object { Write-Host "$_" } Fail-Docker 'Docker is still unavailable after 3 minutes. Wait until Docker Desktop shows "Engine running", then try again.' ($dockerInfo -join "`n") } Ok 'Docker is running.' } $composeVersion = docker compose version 2>&1 if ($LASTEXITCODE -ne 0) { $composeVersion | ForEach-Object { Write-Host "$_" } Die 'Docker Compose v2 is not installed or could not run. Install or update Docker Desktop, then run the same command again.' } $containerOS = docker info --format '{{.OSType}}' 2>$null if ($LASTEXITCODE -ne 0) { Die 'Docker became unavailable while checking its container mode. Check Docker Desktop and try again.' } if (($containerOS -join '').Trim() -ne 'linux') { Die 'Docker is using Windows containers. Switch Docker Desktop to Linux containers, then run the same command again.' } } function Ensure-DataVolumes { $volumes = @( @{ Name = 'ptm-clearwork-mongo-data'; Purpose = 'database' }, @{ Name = 'ptm-clearwork-uploads'; Purpose = 'uploads-secrets-identity' } ) foreach ($volume in $volumes) { docker volume inspect $volume.Name *> $null if ($LASTEXITCODE -eq 0) { continue } Say "Creating protected data volume $($volume.Name)..." Invoke-DockerCommand @('volume', 'create', '--label', 'com.ptm-clearwork.protected=true', '--label', "com.ptm-clearwork.purpose=$($volume.Purpose)", $volume.Name) if ($DockerExitCode -ne 0) { Fail-Docker "Could not create data volume $($volume.Name)." $DockerOutput } } } function Write-Files { try { New-Item -ItemType Directory -Force -Path $PtmHome -ErrorAction Stop | Out-Null # NOTE: ${...} below are compose variable references, not PowerShell - single-quoted here-string keeps them literal. $compose = @' # Managed by the PTM Clearwork installer - do not edit by hand. name: ptm-clearwork services: mongo: image: ${MONGO_IMAGE} restart: unless-stopped labels: { com.ptm-clearwork.protected: "true" } volumes: [mongo_data:/data/db] networks: [internal] server: image: ${SERVER_IMAGE} restart: unless-stopped labels: { com.ptm-clearwork.protected: "true" } ports: ["${BIND_ADDRESS:-127.0.0.1}:${HTTP_PORT:-3124}:3001"] environment: TRUST_PROXY: ${TRUST_PROXY:-} CORS_ORIGINS: ${CORS_ORIGINS:-} SCREENSHOT_RETENTION_DAYS: ${SCREENSHOT_RETENTION_DAYS:-90} LICENSING_PORTAL_URL: ${LICENSING_PORTAL_URL:-} volumes: [uploads:/app/uploads] depends_on: [mongo] networks: [internal] volumes: mongo_data: { external: true, name: ptm-clearwork-mongo-data } uploads: { external: true, name: ptm-clearwork-uploads } networks: internal: { driver: bridge } '@ Set-Content -LiteralPath $ComposeFile -Value $compose -Encoding utf8 -ErrorAction Stop if (-not (Test-Path $EnvFile)) { @( "SERVER_IMAGE=$ServerImage", "MONGO_IMAGE=$MongoImage", "HTTP_PORT=$HttpPort", "BIND_ADDRESS=$BindAddress", "# Set TRUST_PROXY=1 only if you put your own HTTPS reverse proxy in front.", "TRUST_PROXY=$($env:TRUST_PROXY)" ) | Set-Content -LiteralPath $EnvFile -Encoding utf8 -ErrorAction Stop } else { # Refresh only the image pins so `update` pulls the right tags; keep the # rest. A port or bind address given as an environment variable is # persisted so the next plain `start` keeps it. $envLines = @((Get-Content -LiteralPath $EnvFile -ErrorAction Stop) ` -replace '^SERVER_IMAGE=.*', "SERVER_IMAGE=$ServerImage" ` -replace '^MONGO_IMAGE=.*', "MONGO_IMAGE=$MongoImage") if ($HttpPortGiven) { $envLines = @($envLines -replace '^HTTP_PORT=.*', "HTTP_PORT=$HttpPort") if (-not ($envLines -match '^HTTP_PORT=')) { $envLines += "HTTP_PORT=$HttpPort" } } if ($BindAddressGiven) { $envLines = @($envLines -replace '^BIND_ADDRESS=.*', "BIND_ADDRESS=$BindAddress") if (-not ($envLines -match '^BIND_ADDRESS=')) { $envLines += "BIND_ADDRESS=$BindAddress" } } $envLines | Set-Content -LiteralPath $EnvFile -Encoding utf8 -ErrorAction Stop } # The saved settings win over the script defaults for everything printed later. foreach ($line in (Get-Content -LiteralPath $EnvFile -ErrorAction Stop)) { if ($line -match '^HTTP_PORT=(.+)$') { $script:HttpPort = $Matches[1].Trim() } if ($line -match '^BIND_ADDRESS=(.+)$') { $script:BindAddress = $Matches[1].Trim() } } } catch { Die "Could not write installation settings to '$PtmHome'. Check folder permissions and free disk space. $($_.Exception.Message)" } } function Wait-Healthy { Say 'Waiting for the server to become healthy...' for ($i = 0; $i -lt 60; $i += 2) { try { Invoke-WebRequest -UseBasicParsing "http://localhost:$HttpPort/api/v1/health" -TimeoutSec 3 *> $null Ok 'Server is up.'; return } catch { Start-Sleep -Seconds 2 } } Invoke-Compose ps -a Die "The server did not become healthy at http://localhost:$HttpPort/api/v1/health. Check .\install.ps1 logs for database or startup errors and verify HTTP_PORT in '$EnvFile'." } # ---- Uninstall helpers ------------------------------------------------------ $DataVolumeNames = @('ptm-clearwork-mongo-data', 'ptm-clearwork-uploads') # Unnamed volumes Docker created for this project's containers. The mongo image # declares /data/configdb as a VOLUME, and `compose down` (deliberately run # without -v, which must never be able to touch the data volumes) leaves that # one behind forever. Collect them BEFORE the containers are removed - once the # container is gone nothing links the volume back to this install. function Get-StrayVolumes { $found = @() foreach ($id in @(docker ps -a -q --filter "label=com.docker.compose.project=$Project")) { $mounts = docker inspect $id --format '{{range .Mounts}}{{.Type}}:{{.Name}} {{end}}' foreach ($m in ($mounts -split '\s+')) { if ($m -like 'volume:*') { $found += $m.Substring(7) } } } $found | Where-Object { $_ -and ($DataVolumeNames -notcontains $_) } | Select-Object -Unique } # Every local tag of the images this installer pulls (the pinned tags plus any # older ones left behind by `update`). Removed by reference, not by ID, so a tag # shared with something else is only un-tagged instead of failing. function Remove-Images { $names = @($ServerImage, $MongoImage) if (Test-Path -LiteralPath $EnvFile) { $names += Get-Content -LiteralPath $EnvFile | Where-Object { $_ -match '^(SERVER|MONGO)_IMAGE=' } | ForEach-Object { ($_ -split '=', 2)[1].Trim() } } $repos = $names | Where-Object { $_ } | ForEach-Object { $_ -replace ':[^:/]+$', '' } | Select-Object -Unique $images = @(foreach ($repo in $repos) { docker images $repo --format '{{.Repository}}:{{.Tag}}|{{.ID}}' }) if ($images.Count -eq 0) { return } Say 'Removing downloaded images...' foreach ($image in $images) { $ref, $id = $image -split '\|', 2 if ($ref -like '*:') { $ref = $id } docker image rm $ref *> $null if ($LASTEXITCODE -eq 0) { Ok "Removed image $ref" } else { Warn "Could not remove image $ref (still used by another container?)" } } } function Print-NextSteps { Write-Host '' Ok 'PTM Clearwork is running.' Write-Host " Dashboard: http://localhost:$HttpPort/ptm-clearwork-dashboard/" if ($BindAddress -eq '127.0.0.1' -or $BindAddress -eq 'localhost') { Write-Host ' Only this computer can open it. To open it from other computers on your network, run:' Write-Host ' $env:BIND_ADDRESS = "0.0.0.0"; .\install.ps1 start' } else { $addresses = @() try { $addresses = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object -ExpandProperty IPAddress -First 3) } catch {} foreach ($addr in $addresses) { Write-Host " From other computers: http://${addr}:$HttpPort/ptm-clearwork-dashboard/" } Write-Host ' Employee agents need an HTTPS address - see step 4 of the setup guide before enrolling devices.' } Write-Host '' Say 'Open the dashboard and enter the setup code PTM emailed you.' Write-Host " No code yet? Start a free trial or buy a plan: $PortalUrl" Write-Host ' The code arrives by email as soon as your payment or card check is confirmed.' Write-Host '' Write-Host ' Manage it later: install.ps1 [stop|update|logs|status|uninstall]' } switch ($Command) { { $_ -in 'start','' } { Require-Docker; Ensure-DataVolumes; Write-Files Say 'Pulling images...'; Pull-Images Say 'Starting the stack...'; Invoke-Compose up -d Wait-Healthy; Print-NextSteps } 'stop' { Require-Docker; Invoke-Compose stop; Ok 'Stopped.' } 'logs' { Require-Docker; Invoke-Compose logs -f --tail=200 server } 'status' { Require-Docker; Invoke-Compose ps } 'ps' { Require-Docker; Invoke-Compose ps } 'update' { Require-Docker if (-not (Test-Path $ComposeFile)) { Die 'Not installed yet. Run: install.ps1 start' } Ensure-DataVolumes Write-Files Say 'Pulling latest images...'; Pull-Images Say 'Recreating...'; Invoke-Compose up -d Wait-Healthy; Ok 'Updated. Your data and admin account are preserved.' } 'uninstall' { Require-Docker $wipe = $Purge.IsPresent if (-not $wipe) { $ans = Read-Host 'Also DELETE all data (database + screenshots + secrets)? This cannot be undone [y/N]' $wipe = $ans -match '^[Yy]' } # Vet the install folder BEFORE destroying anything, so a refusal can never # leave a half-removed install (data gone, folder still there). $resolvedPtmHome = [IO.Path]::GetFullPath($PtmHome).TrimEnd([IO.Path]::DirectorySeparatorChar) $removeHome = $false if ($wipe -and (Test-Path -LiteralPath $resolvedPtmHome)) { $resolvedProfile = [IO.Path]::GetFullPath($HOME).TrimEnd([IO.Path]::DirectorySeparatorChar) $resolvedRoot = [IO.Path]::GetPathRoot($resolvedPtmHome).TrimEnd([IO.Path]::DirectorySeparatorChar) $ownedCompose = Join-Path $resolvedPtmHome 'compose.yaml' if ($resolvedPtmHome -eq $resolvedProfile -or $resolvedPtmHome -eq $resolvedRoot -or -not (Test-Path -LiteralPath $ownedCompose) -or -not (Select-String -LiteralPath $ownedCompose -Pattern '^# Managed by the PTM Clearwork installer' -Quiet)) { Die "Refusing to remove an unsafe or unowned install directory: $resolvedPtmHome (nothing was deleted)" } $removeHome = $true } $strayVolumes = @(Get-StrayVolumes) Say 'Stopping and removing containers...' if (Test-Path -LiteralPath $ComposeFile) { docker compose -p $Project -f $ComposeFile --env-file $EnvFile down --remove-orphans if ($LASTEXITCODE -ne 0) { Die "Could not remove the containers (exit $LASTEXITCODE). Nothing else was deleted." } } # Safety net for an install whose compose file is already gone. foreach ($id in @(docker ps -a -q --filter "label=com.docker.compose.project=$Project")) { docker rm -f $id *> $null } foreach ($v in $strayVolumes) { docker volume rm $v *> $null } Remove-Images if ($wipe) { foreach ($v in $DataVolumeNames) { docker volume inspect $v *> $null if ($LASTEXITCODE -ne 0) { continue } docker volume rm $v *> $null if ($LASTEXITCODE -eq 0) { Ok "Deleted data volume $v" } else { Warn "Could not delete data volume $v (still in use?)" } } if ($removeHome) { Remove-Item -LiteralPath $resolvedPtmHome -Recurse -Force; Ok "Removed $resolvedPtmHome" } Ok 'PTM Clearwork fully removed, including all data.' } else { Ok 'PTM Clearwork removed. Your data volumes and settings were kept - run "install.ps1 start" to reinstall and reuse them.' } } default { Die "Unknown command '$Command'. Use: start | stop | update | logs | status | uninstall" } }