#!/bin/sh # ============================================================================= # PTM Clearwork - one-command installer / manager (Linux & macOS) # # curl -fsSL https://cleanwork.ptmsoftware.live/install.sh -o ptm-clearwork # chmod +x ptm-clearwork # ./ptm-clearwork start # # It owns a small compose file in ~/.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 - nothing else to copy. # # Subcommands (default is `start`): # start | stop | update | logs | status | uninstall # # ./ptm-clearwork uninstall remove containers + images, ASK before deleting data # ./ptm-clearwork uninstall --purge remove EVERYTHING incl. all data, no questions asked # ============================================================================= set -eu # ---- Published image names (PUBLIC). Replace with your real registry refs. -- SERVER_IMAGE="${PTM_SERVER_IMAGE:-ptmtech/ptm-clearwork-server:1.9.0}" # x-release-please-version MONGO_IMAGE="${PTM_MONGO_IMAGE:-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. PORTAL_URL="${LICENSING_PORTAL_URL:-https://cleanwork.ptmsoftware.live/licence/}" # ---- Where the managed compose + env live (customer never edits these) ------ PTM_HOME="${PTM_HOME:-$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). Pass either on the command line; start persists it. HTTP_PORT_GIVEN="${HTTP_PORT:-}" BIND_ADDRESS_GIVEN="${BIND_ADDRESS:-}" HTTP_PORT="${HTTP_PORT:-3124}" BIND_ADDRESS="${BIND_ADDRESS:-127.0.0.1}" COMPOSE_FILE="$PTM_HOME/compose.yaml" ENV_FILE="$PTM_HOME/.env" say() { printf '\033[1;35m▸\033[0m %s\n' "$1"; } ok() { printf '\033[1;32m✓\033[0m %s\n' "$1"; } warn() { printf '\033[1;33m!\033[0m %s\n' "$1"; } die() { printf '\033[1;31m✗ %s\033[0m\n' "$1" >&2; exit 1; } # Preserve Docker's own error, then explain the cause and the next step. docker_issue() { detail=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$detail" in *'pull access denied'*|*'unauthorized'*|*'authentication required'*|*'manifest unknown'*|*'not found: manifest'*|*'repository does not exist'*) printf '%s' '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'*) printf '%s' 'Docker access was denied. Let your user run Docker with: sudo usermod -aG docker $USER, then log out and back in (or run this command with sudo). On a managed machine, ask your administrator.' ;; *'address already in use'*|*'port is already allocated'*|*'ports are not available'*) printf '%s' "The dashboard port is unavailable. Stop the application using it, or choose a free HTTP_PORT in '$ENV_FILE', then run the same command again." ;; *'no space left on device'*|*'not enough space'*|*'disk full'*) printf '%s' 'Docker has run out of disk space. Free space on the host and in Docker, then try again. Do not delete the PTM data volumes.' ;; *'x509:'*|*'certificate'*|*'tls handshake'*) printf '%s' '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'*) printf '%s' 'The Docker image does not support the selected platform. Use Linux containers and a supported processor architecture.' ;; *'toomanyrequests'*|*'too many requests'*|*'pull rate limit'*) printf '%s' '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'*) printf '%s' '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'*) printf '%s' 'Docker is not running or its configured engine cannot be reached. Start Docker (Docker Desktop on macOS; sudo systemctl start docker on Linux), then check docker context ls and try 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'*) printf '%s' 'Docker could not reach the registry or configured engine. Check your internet connection, DNS, VPN and proxy settings, then try again.' ;; esac } fail_docker() { issue=$(docker_issue "$2") [ -n "$issue" ] || issue='See the Docker error above. Check Docker and run ./ptm-clearwork logs for server startup errors.' die "$1 $issue" } # Stream progress while recording the native status separately from tee's status. run_docker() { docker_log=$(mktemp -d "${TMPDIR:-/tmp}/ptm-clearwork.XXXXXX") || die "Could not create a temporary Docker log. Check temporary-folder permissions and free disk space." if { if docker "$@" 2>&1; then docker_status=0; else docker_status=$?; fi printf '%s\n' "$docker_status" > "$docker_log/status" } | tee "$docker_log/output"; then docker_exit=$(cat "$docker_log/status") else docker_exit=1 fi docker_output=$(cat "$docker_log/output") rm -f "$docker_log/status" "$docker_log/output" rmdir "$docker_log" return "$docker_exit" } raw_compose() { run_docker compose -p "$PROJECT" -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"; } compose() { [ -f "$COMPOSE_FILE" ] && [ -f "$ENV_FILE" ] || die "Installation settings are missing. Run: ./ptm-clearwork start" # Do not accumulate an unbounded transcript when following logs. if [ "$1" = logs ]; then docker compose -p "$PROJECT" -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" || die "Could not read server logs. Check that Docker is running and this account can access it; see the Docker error above." return 0 fi raw_compose "$@" || fail_docker "Docker Compose '$1' failed (exit $docker_exit)." "$docker_output" } # Installed but stopped: start Docker ourselves where that needs no password # (Docker Desktop on macOS, the docker service when already root), then wait. start_docker() { if [ "$(uname -s)" = "Darwin" ] && open -a Docker >/dev/null 2>&1; then say "Docker is not running - starting Docker Desktop (this can take a minute)..." elif [ "$(id -u)" -eq 0 ] && command -v systemctl >/dev/null 2>&1 && systemctl start docker >/dev/null 2>&1; then say "Docker was not running - started the docker service." else printf '%s\n' "$docker_info" >&2 fail_docker "Docker is not running or its configured engine cannot be reached." "$docker_info" fi waited=0 until docker_info=$(docker info 2>&1); do if [ "$waited" -ge 180 ]; then printf '\n%s\n' "$docker_info" >&2 fail_docker "Docker is still unavailable after 3 minutes. Wait for its engine to start, then try again." "$docker_info" fi sleep 3; waited=$((waited + 3)); printf '.' done printf '\n'; ok "Docker is running." } # Docker is missing entirely. Explain what it is, offer Docker's official # install on Linux when we can ask (a terminal) and have root or sudo, and # otherwise print the exact steps. On macOS Docker Desktop needs a first launch # (licence terms) that only the user can do, so we always stop there. install_docker_guidance() { warn "Docker is not installed. PTM Clearwork runs inside Docker." if [ "$(uname -s)" = "Darwin" ]; then printf '\n%s\n' 'How to install Docker Desktop on this Mac (about 10 minutes):' if command -v brew >/dev/null 2>&1; then printf '%s\n' ' 1. Run: brew install --cask docker' else printf '%s\n' ' 1. Download Docker Desktop (choose Apple silicon or Intel): https://docs.docker.com/desktop/setup/install/mac-install/' fi printf '%s\n' ' 2. Open Docker from Applications, accept its terms and wait until it shows "Engine running".' printf '%s\n' ' 3. Run the same command again. It continues from here.' exit 1 fi if [ "$(id -u)" -eq 0 ]; then sudo_cmd=""; need="root" elif command -v sudo >/dev/null 2>&1; then sudo_cmd="sudo "; need="sudo" else sudo_cmd=""; need="" fi if [ -t 0 ] && [ -n "$need" ]; then printf "Install Docker now with Docker's official script (needs %s)? [Y/n]: " "$need" read -r ans || ans=n case "$ans" in ""|y|Y|yes|YES) say "Installing Docker Engine (downloads about 300 MB)..." get_docker="${TMPDIR:-/tmp}/ptm-get-docker.$$.sh" if curl -fsSL https://get.docker.com -o "$get_docker" && ${sudo_cmd}sh "$get_docker"; then rm -f "$get_docker" ${sudo_cmd}systemctl enable --now docker >/dev/null 2>&1 || true if [ "$(id -u)" -ne 0 ]; then ${sudo_cmd}usermod -aG docker "$(id -un)" >/dev/null 2>&1 || true ok "Docker is installed and your user was added to the docker group." printf '%s\n' 'Log out and back in (or run: newgrp docker), then run the same command again. It continues from here.' exit 1 fi ok "Docker is installed." return 0 fi rm -f "$get_docker" warn "The automatic install did not finish. Install Docker with the steps below." ;; esac fi printf '\n%s\n' 'How to install Docker on this Linux machine:' printf '%s\n' ' 1. Follow https://docs.docker.com/engine/install/ for your distribution (Ubuntu, Debian, Fedora, RHEL, ...).' printf '%s\n' ' Quick path on most distributions: curl -fsSL https://get.docker.com | sudo sh' printf '%s\n' ' 2. Let your user run Docker: sudo usermod -aG docker $USER then log out and back in.' printf '%s\n' ' 3. Run the same command again. It continues from here.' exit 1 } require_docker() { command -v docker >/dev/null 2>&1 || install_docker_guidance if ! docker_info=$(docker info 2>&1); then # Do not try starting a service for permission, TLS or context failures. detail=$(printf '%s' "$docker_info" | tr '[:upper:]' '[:lower:]') case "$detail" in *'permission denied'*|*'access denied'*|*'access is 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'*) printf '%s\n' "$docker_info" >&2 fail_docker "Cannot access Docker." "$docker_info" ;; *) start_docker ;; esac fi if ! compose_version=$(docker compose version 2>&1); then printf '%s\n' "$compose_version" >&2 die "Docker Compose v2 is not installed or could not run. Install or update the Docker Compose plugin (or Docker Desktop), then try again." fi } ensure_data_volumes() { for spec in \ "ptm-clearwork-mongo-data:database" \ "ptm-clearwork-uploads:uploads-secrets-identity" do name=${spec%%:*} purpose=${spec#*:} if docker volume inspect "$name" >/dev/null 2>&1; then continue fi say "Creating protected data volume $name..." run_docker volume create \ --label com.ptm-clearwork.protected=true \ --label "com.ptm-clearwork.purpose=$purpose" \ "$name" || fail_docker "Could not create data volume $name." "$docker_output" done } write_files() { mkdir -p "$PTM_HOME" || die "Could not create installation folder '$PTM_HOME'. Check folder permissions and free disk space." cat > "$COMPOSE_FILE" < "$ENV_FILE" <> "$ENV_FILE"; } [ -n "$BIND_ADDRESS_GIVEN" ] && { sed -i.bak "s#^BIND_ADDRESS=.*#BIND_ADDRESS=$BIND_ADDRESS#" "$ENV_FILE" || die "Could not update '$ENV_FILE'."; grep -q "^BIND_ADDRESS=" "$ENV_FILE" || echo "BIND_ADDRESS=$BIND_ADDRESS" >> "$ENV_FILE"; } rm -f "$ENV_FILE.bak" || die "Could not remove settings backup '$ENV_FILE.bak'. Check folder permissions." fi # The saved settings win over the script defaults for everything printed below. saved=$(grep "^HTTP_PORT=" "$ENV_FILE" | tail -1 | cut -d= -f2-); [ -n "$saved" ] && HTTP_PORT=$saved saved=$(grep "^BIND_ADDRESS=" "$ENV_FILE" | tail -1 | cut -d= -f2-); [ -n "$saved" ] && BIND_ADDRESS=$saved } # Best-effort list of this machine's IPv4 addresses, for the "open it from # another computer" hint. Empty output is fine. lan_addresses() { { if hostname -I >/dev/null 2>&1; then hostname -I 2>/dev/null | tr ' ' '\n' elif command -v ip >/dev/null 2>&1; then ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 elif command -v ifconfig >/dev/null 2>&1; then ifconfig 2>/dev/null | awk '/inet / {print $2}' fi } | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -v '^127\.' | head -3 } wait_healthy() { say "Waiting for the server to become healthy..." i=0 while [ "$i" -lt 60 ]; do if curl -fsS --connect-timeout 2 --max-time 3 "http://localhost:$HTTP_PORT/api/v1/health" >/dev/null 2>&1; then ok "Server is up." return 0 fi i=$((i + 2)); sleep 2 done compose ps -a die "The server did not become healthy at http://localhost:$HTTP_PORT/api/v1/health. Check ./ptm-clearwork logs for database or startup errors and verify HTTP_PORT in '$ENV_FILE'." } print_next_steps() { echo ok "PTM Clearwork is running." echo " Dashboard: http://localhost:$HTTP_PORT/ptm-clearwork-dashboard/" if [ "$BIND_ADDRESS" = "127.0.0.1" ] || [ "$BIND_ADDRESS" = "localhost" ]; then echo " Only this computer can open it. To open it from other computers on your network, run:" echo " BIND_ADDRESS=0.0.0.0 ./ptm-clearwork start" else for addr in $(lan_addresses); do echo " From other computers: http://$addr:$HTTP_PORT/ptm-clearwork-dashboard/" done echo " Employee agents need an HTTPS address - see step 4 of the setup guide before enrolling devices." fi echo say "Open the dashboard and enter the setup code PTM emailed you." echo " No code yet? Start a free trial or buy a plan: $PORTAL_URL" echo " The code arrives by email as soon as your payment or card check is confirmed." echo echo " Manage it later: ./ptm-clearwork [stop|update|logs|status|uninstall]" } # 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. pull_images() { # --quiet: without a terminal (curl | bash) Docker prints every progress # tick as a new line, which looks like something is wrong. Errors are still # captured and shown by fail_docker. say "The first download is a few hundred MB and can take a few minutes..." attempt=1 until raw_compose pull --quiet; do detail=$(printf '%s' "$docker_output" | tr '[:upper:]' '[:lower:]') case "$detail" in *'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." "$docker_output" ;; esac [ "$attempt" -ge 3 ] && fail_docker "Could not download the Docker images after 3 attempts." "$docker_output" attempt=$((attempt + 1)) warn "Download failed - retrying in 10 seconds (attempt $attempt of 3)..." sleep 10 done } cmd_start() { require_docker command -v curl >/dev/null 2>&1 || die "curl is not installed. Install curl to check server health, then run the same command again." ensure_data_volumes write_files say "Pulling images..." pull_images say "Starting the stack..." compose up -d wait_healthy print_next_steps } cmd_stop() { require_docker; compose stop; ok "Stopped."; } cmd_logs() { require_docker; compose logs -f --tail=200 server; } cmd_status() { require_docker; compose ps; } cmd_update() { require_docker [ -f "$COMPOSE_FILE" ] || die "Not installed yet. Run: ./ptm-clearwork start" command -v curl >/dev/null 2>&1 || die "curl is not installed. Install curl to check server health, then run the same command again." ensure_data_volumes write_files say "Pulling latest images..."; pull_images say "Recreating..."; compose up -d wait_healthy ok "Updated. Your data and admin account are preserved." } DATA_VOLUMES="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. stray_volumes() { for cid in $(docker ps -a -q --filter "label=com.docker.compose.project=$PROJECT"); do docker inspect "$cid" --format '{{range .Mounts}}{{.Type}}:{{.Name}} {{end}}' done | tr ' ' '\n' | sed -n 's/^volume://p' \ | grep -v -x -e ptm-clearwork-mongo-data -e ptm-clearwork-uploads | sort -u || true } # 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. remove_images() { names="$SERVER_IMAGE $MONGO_IMAGE" if [ -f "$ENV_FILE" ]; then names="$names $(grep -E '^(SERVER|MONGO)_IMAGE=' "$ENV_FILE" | cut -d= -f2- | tr -d '\r' | tr '\n' ' ')" fi images=$(for n in $names; do printf '%s\n' "$n"; done | sed -E 's#:[^:/]+$##' | sort -u \ | while IFS= read -r repo; do docker images "$repo" --format '{{.Repository}}:{{.Tag}}|{{.ID}}'; done) [ -n "$images" ] || return 0 say "Removing downloaded images..." printf '%s\n' "$images" | while IFS='|' read -r ref id; do case "$ref" in *:'') ref="$id" ;; esac if docker image rm "$ref" >/dev/null 2>&1; then ok "Removed image $ref" else warn "Could not remove image $ref (still used by another container?)" fi done } cmd_uninstall() { purge=0 for arg in "$@"; do case "$arg" in --purge) purge=1 ;; *) die "Unknown option '$arg'. Use: ./ptm-clearwork uninstall [--purge]" ;; esac done require_docker wipe=$purge if [ "$purge" -eq 0 ]; then printf "Also DELETE all data (database + screenshots + secrets)? This cannot be undone [y/N]: " read -r ans || ans=n case "$ans" in y|Y) wipe=1 ;; esac fi # Vet the install folder BEFORE destroying anything, so a refusal can never # leave a half-removed install (data gone, folder still there). remove_home=0 if [ "$wipe" -eq 1 ] && [ -d "$PTM_HOME" ]; then resolved_home=$(cd "$PTM_HOME" && pwd -P) [ -n "$resolved_home" ] && [ "$resolved_home" != "/" ] && [ "$resolved_home" != "$HOME" ] || \ die "Refusing to remove unsafe install directory: $resolved_home (nothing was deleted)" [ -f "$resolved_home/compose.yaml" ] && \ grep -q '^# Managed by the PTM Clearwork installer' "$resolved_home/compose.yaml" || \ die "Refusing to remove a directory not owned by PTM Clearwork: $resolved_home (nothing was deleted)" remove_home=1 fi strays=$(stray_volumes) say "Stopping and removing containers..." if [ -f "$COMPOSE_FILE" ]; then compose down --remove-orphans || die "Could not remove the containers. Nothing else was deleted." fi # Safety net for an install whose compose file is already gone. for cid in $(docker ps -a -q --filter "label=com.docker.compose.project=$PROJECT"); do docker rm -f "$cid" >/dev/null 2>&1 || true done for v in $strays; do docker volume rm "$v" >/dev/null 2>&1 || true; done remove_images if [ "$wipe" -eq 1 ]; then for v in $DATA_VOLUMES; do docker volume inspect "$v" >/dev/null 2>&1 || continue if docker volume rm "$v" >/dev/null 2>&1; then ok "Deleted data volume $v" else warn "Could not delete data volume $v (still in use?)" fi done if [ "$remove_home" -eq 1 ]; then rm -rf -- "$resolved_home" ok "Removed $resolved_home" fi ok "PTM Clearwork fully removed, including all data." else ok "PTM Clearwork removed. Your data volumes and settings were kept - run \"./ptm-clearwork start\" to reinstall and reuse them." fi } case "${1:-start}" in start|"") cmd_start ;; stop) cmd_stop ;; update) cmd_update ;; logs) cmd_logs ;; status|ps) cmd_status ;; uninstall) shift; cmd_uninstall "$@" ;; *) die "Unknown command '$1'. Use: start | stop | update | logs | status | uninstall" ;; esac