From a8088483efa44a6ca9075b20629d1929c74ea9e9 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 21:47:29 +0200
Subject: [PATCH 01/20] feat(docker): add local-build runtime scaffold
---
docker/Dockerfile | 60 ++++++++++++++++++++++++++++++++++
docker/compose.yml | 81 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 141 insertions(+)
create mode 100644 docker/Dockerfile
create mode 100644 docker/compose.yml
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..bc68941
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,60 @@
+# syntax=docker/dockerfile:1.7
+
+ARG NODE_VERSION=22-bookworm-slim
+
+FROM node:${NODE_VERSION} AS package
+
+ARG PI_WEB_VERSION=latest
+ARG PI_VERSION=latest
+ARG CACHE_BUST=local
+
+ENV NPM_CONFIG_UPDATE_NOTIFIER=false
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates g++ make python3 \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN set -eux; \
+ echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \
+ npm install -g --omit=dev --no-audit --no-fund \
+ "@jmfederico/pi-web@${PI_WEB_VERSION}" \
+ "@earendil-works/pi-coding-agent@${PI_VERSION}"; \
+ npm cache clean --force
+
+FROM node:${NODE_VERSION} AS runtime
+
+ENV NODE_ENV=production \
+ NPM_CONFIG_UPDATE_NOTIFIER=false \
+ HOME=/data/home \
+ XDG_CONFIG_HOME=/data/config \
+ PI_WEB_HOST=0.0.0.0 \
+ PI_WEB_PORT=8504 \
+ PI_WEB_DATA_DIR=/data/pi-web \
+ PI_WEB_SESSIOND_SOCKET=/data/pi-web/sessiond.sock \
+ PI_CODING_AGENT_DIR=/data/pi-agent \
+ SHELL=/bin/bash \
+ TERM=xterm-256color
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ bash \
+ ca-certificates \
+ curl \
+ git \
+ openssh-client \
+ procps \
+ tini \
+ && rm -rf /var/lib/apt/lists/* \
+ && mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \
+ && chown -R node:node /data /workspace
+
+COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
+COPY --from=package /usr/local/bin /usr/local/bin
+
+WORKDIR /workspace
+USER node
+
+EXPOSE 8504
+
+ENTRYPOINT ["tini", "--"]
+CMD ["pi-web-server"]
diff --git a/docker/compose.yml b/docker/compose.yml
new file mode 100644
index 0000000..7fd5975
--- /dev/null
+++ b/docker/compose.yml
@@ -0,0 +1,81 @@
+name: pi-web
+
+x-pi-web-build: &pi-web-build
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PI_WEB_VERSION: ${PI_WEB_VERSION:-latest}
+ PI_VERSION: ${PI_VERSION:-latest}
+ CACHE_BUST: ${CACHE_BUST:-local}
+
+x-pi-web-environment: &pi-web-environment
+ HOME: /data/home
+ XDG_CONFIG_HOME: /data/config
+ PI_WEB_DATA_DIR: /data/pi-web
+ PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock
+ PI_CODING_AGENT_DIR: /data/pi-agent
+ PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
+
+x-pi-web-volumes: &pi-web-volumes
+ - type: bind
+ source: ${PI_WEB_DOCKER_DATA_DIR:-./data}
+ target: /data
+ - type: bind
+ source: /var/run/docker.sock
+ target: /var/run/docker.sock
+ - type: bind
+ source: /srv
+ target: /srv
+ - type: bind
+ source: /opt
+ target: /opt
+ - type: bind
+ source: /home
+ target: /home
+ - type: bind
+ source: /
+ target: /host
+ read_only: true
+
+services:
+ sessiond:
+ build: *pi-web-build
+ image: ${PI_WEB_IMAGE:-pi-web:local}
+ command: ["pi-web-sessiond"]
+ restart: unless-stopped
+ user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
+ group_add:
+ - "${DOCKER_GID:-0}"
+ environment: *pi-web-environment
+ volumes: *pi-web-volumes
+ healthcheck:
+ test: ["CMD-SHELL", "test -S /data/pi-web/sessiond.sock"]
+ interval: 10s
+ timeout: 3s
+ retries: 12
+ start_period: 10s
+
+ web:
+ build: *pi-web-build
+ image: ${PI_WEB_IMAGE:-pi-web:local}
+ command: ["pi-web-server"]
+ restart: unless-stopped
+ depends_on:
+ sessiond:
+ condition: service_healthy
+ user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
+ group_add:
+ - "${DOCKER_GID:-0}"
+ environment:
+ <<: *pi-web-environment
+ PI_WEB_HOST: 0.0.0.0
+ PI_WEB_PORT: "8504"
+ ports:
+ - "${PI_WEB_BIND_ADDR:-127.0.0.1}:${PI_WEB_PORT:-8504}:8504"
+ volumes: *pi-web-volumes
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8504/api/pi-web/runtime >/dev/null"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 10s
From 099d770eebdf861962f180a5b3c02e46301b1b76 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 21:58:53 +0200
Subject: [PATCH 02/20] feat(docker): add host command bridge
---
.changeset/docker-host-command-bridge.md | 5 +++
docker/Dockerfile | 6 +++
docker/bin/hostexec | 48 ++++++++++++++++++++++++
docker/compose.yml | 5 +++
4 files changed, 64 insertions(+)
create mode 100644 .changeset/docker-host-command-bridge.md
create mode 100755 docker/bin/hostexec
diff --git a/.changeset/docker-host-command-bridge.md b/.changeset/docker-host-command-bridge.md
new file mode 100644
index 0000000..b740e7d
--- /dev/null
+++ b/.changeset/docker-host-command-bridge.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add a Docker runtime host command bridge for explicitly running host administration commands from containerized PI WEB sessions.
diff --git a/docker/Dockerfile b/docker/Dockerfile
index bc68941..787aad0 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,6 +1,9 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=22-bookworm-slim
+ARG DOCKER_CLI_VERSION=29-cli
+
+FROM docker:${DOCKER_CLI_VERSION} AS docker-cli
FROM node:${NODE_VERSION} AS package
@@ -32,6 +35,7 @@ ENV NODE_ENV=production \
PI_WEB_DATA_DIR=/data/pi-web \
PI_WEB_SESSIOND_SOCKET=/data/pi-web/sessiond.sock \
PI_CODING_AGENT_DIR=/data/pi-agent \
+ HOSTEXEC_IMAGE=alpine:3.22 \
SHELL=/bin/bash \
TERM=xterm-256color
@@ -50,6 +54,8 @@ RUN apt-get update \
COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=package /usr/local/bin /usr/local/bin
+COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+COPY --chmod=0755 bin/hostexec /usr/local/bin/hostexec
WORKDIR /workspace
USER node
diff --git a/docker/bin/hostexec b/docker/bin/hostexec
new file mode 100755
index 0000000..f70cc61
--- /dev/null
+++ b/docker/bin/hostexec
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat >&2 <<'EOF'
+Usage: hostexec
+
+Run a command on the Docker host by starting a temporary privileged helper
+container through the mounted Docker socket and entering the host namespaces.
+EOF
+}
+
+if [ "$#" -eq 0 ]; then
+ usage
+ exit 64
+fi
+
+if ! command -v docker >/dev/null 2>&1; then
+ echo "hostexec: docker CLI not found in this container" >&2
+ exit 127
+fi
+
+docker_host="${DOCKER_HOST:-unix:///var/run/docker.sock}"
+if [[ "$docker_host" == unix://* ]]; then
+ socket_path="${docker_host#unix://}"
+ if [ ! -S "$socket_path" ]; then
+ echo "hostexec: Docker socket is not accessible as a Unix socket at $socket_path" >&2
+ exit 69
+ fi
+fi
+
+helper_image="${HOSTEXEC_IMAGE:-alpine:3.22}"
+tty_args=(--interactive)
+if [ -t 0 ] && [ -t 1 ]; then
+ tty_args+=(--tty)
+fi
+
+exec docker run \
+ --rm \
+ "${tty_args[@]}" \
+ --pull=missing \
+ --privileged \
+ --security-opt label=disable \
+ --pid=host \
+ --network=host \
+ --volume /:/host:rw \
+ "$helper_image" \
+ nsenter -t 1 -m -u -i -n -p -- "$@"
diff --git a/docker/compose.yml b/docker/compose.yml
index 7fd5975..b98ac1c 100644
--- a/docker/compose.yml
+++ b/docker/compose.yml
@@ -14,6 +14,7 @@ x-pi-web-environment: &pi-web-environment
PI_WEB_DATA_DIR: /data/pi-web
PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock
PI_CODING_AGENT_DIR: /data/pi-agent
+ HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
x-pi-web-volumes: &pi-web-volumes
@@ -46,6 +47,8 @@ services:
user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
group_add:
- "${DOCKER_GID:-0}"
+ security_opt:
+ - label=disable
environment: *pi-web-environment
volumes: *pi-web-volumes
healthcheck:
@@ -66,6 +69,8 @@ services:
user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
group_add:
- "${DOCKER_GID:-0}"
+ security_opt:
+ - label=disable
environment:
<<: *pi-web-environment
PI_WEB_HOST: 0.0.0.0
From 1fa17260836a7d1e90808bf3c7009ed69d529993 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 22:08:19 +0200
Subject: [PATCH 03/20] feat(docker): add one-liner installer
---
.changeset/docker-installer-update.md | 5 +
docker/.dockerignore | 5 +
docker/install.sh | 407 ++++++++++++++++++++++++++
3 files changed, 417 insertions(+)
create mode 100644 .changeset/docker-installer-update.md
create mode 100644 docker/.dockerignore
create mode 100755 docker/install.sh
diff --git a/.changeset/docker-installer-update.md b/.changeset/docker-installer-update.md
new file mode 100644
index 0000000..2bb5d9d
--- /dev/null
+++ b/.changeset/docker-installer-update.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add a one-liner Docker install/update flow that refreshes local runtime assets, preserves persistent data, rebuilds without cache, and recreates the split PI WEB services.
diff --git a/docker/.dockerignore b/docker/.dockerignore
new file mode 100644
index 0000000..1bb8da0
--- /dev/null
+++ b/docker/.dockerignore
@@ -0,0 +1,5 @@
+# Keep the local-build runtime context small and avoid sending persistent data.
+*
+!Dockerfile
+!bin/
+!bin/hostexec
diff --git a/docker/install.sh b/docker/install.sh
new file mode 100755
index 0000000..6800042
--- /dev/null
+++ b/docker/install.sh
@@ -0,0 +1,407 @@
+#!/usr/bin/env sh
+set -eu
+
+log() {
+ printf '%s\n' "$*" >&2
+}
+
+die() {
+ log "pi-web Docker installer: $*"
+ exit 1
+}
+
+usage() {
+ cat <<'EOF'
+Usage: docker/install.sh [options]
+
+Install or update the local-build PI WEB Docker runtime. The installer refreshes
+Docker assets in the install directory, writes host-specific .env values,
+rebuilds the image without using cache, and recreates the split sessiond/web
+services without deleting persistent data.
+
+Options:
+ --install-dir DIR Install directory (default: $XDG_DATA_HOME/pi-web-docker
+ or ~/.local/share/pi-web-docker)
+ --data-dir DIR Persistent data directory (default: INSTALL_DIR/data)
+ --bind-address ADDR Host bind address (default: 127.0.0.1)
+ --port PORT Host port (default: 8504)
+ --pi-web-version VER npm @jmfederico/pi-web version pin (default: latest)
+ --pi-version VER npm @earendil-works/pi-coding-agent version pin
+ (default: latest)
+ --asset-dir DIR Copy Docker assets from a local docker/ directory
+ --asset-ref REF Fetch Docker assets from a Git ref (default: main)
+ --skip-compose Write assets/.env but skip build and service recreate
+ -h, --help Show this help
+
+Environment variables with the same names used in .env may also be set before
+running the installer, for example:
+
+ PI_WEB_VERSION=1.202606.4 PI_VERSION=0.79.1 docker/install.sh
+EOF
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --install-dir)
+ [ "$#" -ge 2 ] || die "--install-dir requires a value"
+ PI_WEB_DOCKER_HOME=$2
+ shift 2
+ ;;
+ --data-dir)
+ [ "$#" -ge 2 ] || die "--data-dir requires a value"
+ PI_WEB_DOCKER_DATA_DIR=$2
+ shift 2
+ ;;
+ --bind-address)
+ [ "$#" -ge 2 ] || die "--bind-address requires a value"
+ PI_WEB_BIND_ADDR=$2
+ shift 2
+ ;;
+ --port)
+ [ "$#" -ge 2 ] || die "--port requires a value"
+ PI_WEB_PORT=$2
+ shift 2
+ ;;
+ --pi-web-version)
+ [ "$#" -ge 2 ] || die "--pi-web-version requires a value"
+ PI_WEB_VERSION=$2
+ shift 2
+ ;;
+ --pi-version)
+ [ "$#" -ge 2 ] || die "--pi-version requires a value"
+ PI_VERSION=$2
+ shift 2
+ ;;
+ --asset-dir)
+ [ "$#" -ge 2 ] || die "--asset-dir requires a value"
+ PI_WEB_DOCKER_ASSET_DIR=$2
+ shift 2
+ ;;
+ --asset-ref)
+ [ "$#" -ge 2 ] || die "--asset-ref requires a value"
+ PI_WEB_DOCKER_REF=$2
+ shift 2
+ ;;
+ --skip-compose)
+ PI_WEB_DOCKER_SKIP_COMPOSE=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ die "unknown argument: $1"
+ ;;
+ esac
+done
+
+absolute_dir() {
+ dir=$1
+ mkdir -p "$dir" || return 1
+ (cd "$dir" && pwd -P)
+}
+
+absolute_existing_dir() {
+ dir=$1
+ (cd "$dir" && pwd -P)
+}
+
+path_from_base() {
+ base=$1
+ path=$2
+ case "$path" in
+ /*) printf '%s\n' "$path" ;;
+ *) printf '%s/%s\n' "$base" "$path" ;;
+ esac
+}
+
+strip_wrapping_quotes() {
+ value=$1
+ case "$value" in
+ \"*\")
+ case "$value" in
+ *\") value=${value#\"}; value=${value%\"} ;;
+ esac
+ ;;
+ \'*\')
+ case "$value" in
+ *\') value=${value#\'}; value=${value%\'} ;;
+ esac
+ ;;
+ esac
+ printf '%s\n' "$value"
+}
+
+existing_env_value() {
+ key=$1
+ [ -f "$env_file" ] || return 1
+ raw=$(awk -v key="$key" '
+ function trim(value) {
+ sub(/^[ \t]+/, "", value)
+ sub(/[ \t\r]+$/, "", value)
+ return value
+ }
+ /^[ \t]*(#|$)/ { next }
+ {
+ line = $0
+ sub(/^[ \t]*export[ \t]+/, "", line)
+ name = line
+ sub(/=.*/, "", name)
+ name = trim(name)
+ if (name == key) {
+ sub(/^[^=]*=/, "", line)
+ print trim(line)
+ found = 1
+ exit
+ }
+ }
+ END { if (!found) exit 1 }
+ ' "$env_file") || return 1
+ strip_wrapping_quotes "$raw"
+}
+
+value_from_env_or_default() {
+ key=$1
+ default_value=$2
+ eval "is_set=\${$key+x}"
+ if [ "${is_set:-}" = x ]; then
+ eval "printf '%s\n' \"\${$key}\""
+ else
+ printf '%s\n' "$default_value"
+ fi
+}
+
+value_from_env_or_existing_or_default() {
+ key=$1
+ default_value=$2
+ eval "is_set=\${$key+x}"
+ if [ "${is_set:-}" = x ]; then
+ eval "printf '%s\n' \"\${$key}\""
+ elif existing=$(existing_env_value "$key"); then
+ printf '%s\n' "$existing"
+ else
+ printf '%s\n' "$default_value"
+ fi
+}
+
+require_non_empty() {
+ name=$1
+ value=$2
+ [ -n "$value" ] || die "$name must not be empty"
+}
+
+detect_docker_gid() {
+ if [ -S /var/run/docker.sock ]; then
+ if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ fi
+
+ if command -v getent >/dev/null 2>&1; then
+ if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then
+ if [ -n "$gid" ]; then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ fi
+ fi
+
+ printf '0\n'
+}
+
+fetch_url() {
+ url=$1
+ target=$2
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$url" -o "$target"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "$target" "$url"
+ else
+ die "curl or wget is required to fetch Docker assets"
+ fi
+}
+
+find_local_asset_dir() {
+ if [ -f "${0:-}" ]; then
+ candidate_dir=$(dirname "$0")
+ if candidate_dir=$(absolute_existing_dir "$candidate_dir" 2>/dev/null); then
+ if [ -f "$candidate_dir/Dockerfile" ] && [ -f "$candidate_dir/compose.yml" ]; then
+ printf '%s\n' "$candidate_dir"
+ return 0
+ fi
+ fi
+ fi
+
+ return 1
+}
+
+write_asset() {
+ rel_path=$1
+ mode=$2
+ target=$install_dir/$rel_path
+ temp_target=$target.$$
+ mkdir -p "$(dirname "$target")"
+
+ if [ -n "$asset_dir" ]; then
+ [ -f "$asset_dir/$rel_path" ] || die "missing Docker asset: $asset_dir/$rel_path"
+ cp "$asset_dir/$rel_path" "$temp_target"
+ else
+ fetch_url "$asset_base/$rel_path" "$temp_target"
+ fi
+
+ chmod "$mode" "$temp_target"
+ mv "$temp_target" "$target"
+}
+
+compose_cmd() {
+ if docker compose version >/dev/null 2>&1; then
+ docker compose "$@"
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose "$@"
+ else
+ die "Docker Compose is required (docker compose plugin or docker-compose)"
+ fi
+}
+
+if [ -n "${XDG_DATA_HOME:-}" ]; then
+ default_data_home=$XDG_DATA_HOME
+elif [ -n "${HOME:-}" ]; then
+ default_data_home=$HOME/.local/share
+else
+ default_data_home=
+fi
+
+default_install_dir=
+if [ -n "$default_data_home" ]; then
+ default_install_dir=$default_data_home/pi-web-docker
+fi
+install_dir_input=${PI_WEB_DOCKER_HOME:-$default_install_dir}
+[ -n "$install_dir_input" ] || die "HOME, XDG_DATA_HOME, or PI_WEB_DOCKER_HOME must be set"
+install_dir=$(absolute_dir "$install_dir_input") || die "could not create install directory"
+env_file=$install_dir/.env
+
+if [ "${PI_WEB_DOCKER_ASSET_DIR+x}" = x ]; then
+ asset_dir=$(absolute_existing_dir "$PI_WEB_DOCKER_ASSET_DIR") || die "asset directory does not exist: $PI_WEB_DOCKER_ASSET_DIR"
+ asset_base=
+ log "Using Docker assets from $asset_dir"
+elif asset_dir=$(find_local_asset_dir 2>/dev/null); then
+ asset_base=
+ log "Using Docker assets from $asset_dir"
+else
+ asset_ref=${PI_WEB_DOCKER_REF:-main}
+ asset_base=${PI_WEB_DOCKER_ASSET_BASE:-https://raw.githubusercontent.com/jmfederico/pi-web/$asset_ref/docker}
+ asset_dir=
+ log "Fetching Docker assets from $asset_base"
+fi
+
+write_asset Dockerfile 0644
+write_asset compose.yml 0644
+write_asset .dockerignore 0644
+write_asset install.sh 0755
+write_asset bin/hostexec 0755
+
+pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)")
+pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)")
+docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)")
+
+raw_data_dir=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_DATA_DIR "$install_dir/data")
+data_dir=$(absolute_dir "$(path_from_base "$install_dir" "$raw_data_dir")") || die "could not create data directory"
+
+pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0.0.1)
+pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504)
+pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest)
+pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest)
+pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local)
+hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22)
+pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
+
+require_non_empty PI_WEB_UID "$pi_web_uid"
+require_non_empty PI_WEB_GID "$pi_web_gid"
+require_non_empty DOCKER_GID "$docker_gid"
+require_non_empty PI_WEB_DOCKER_DATA_DIR "$data_dir"
+require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr"
+require_non_empty PI_WEB_PORT "$pi_web_port"
+require_non_empty PI_WEB_VERSION "$pi_web_version"
+require_non_empty PI_VERSION "$pi_version"
+require_non_empty PI_WEB_IMAGE "$pi_web_image"
+require_non_empty HOSTEXEC_IMAGE "$hostexec_image"
+require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes"
+
+umask 077
+temp_env=$env_file.$$
+cat >"$temp_env" </dev/null 2>&1; then
+ die "docker CLI is required"
+fi
+
+if ! docker info >/dev/null 2>&1; then
+ die "docker daemon is not reachable by this user"
+fi
+
+cache_bust=${CACHE_BUST:-install-$(date -u +%Y%m%dT%H%M%SZ)}
+
+log ""
+log "WARNING: updating recreates the PI WEB Docker session daemon."
+log "Active Pi agent runtimes inside this Docker install can stop; update while sessions are idle."
+log "Persistent data under $data_dir is kept. The installer does not run 'docker compose down -v'."
+log ""
+log "Building $pi_web_image with --pull --no-cache (CACHE_BUST=$cache_bust) ..."
+(
+ cd "$install_dir"
+ CACHE_BUST=$cache_bust compose_cmd -f compose.yml build --pull --no-cache
+)
+
+log "Recreating split PI WEB Docker services ..."
+(
+ cd "$install_dir"
+ compose_cmd -f compose.yml up -d --force-recreate --remove-orphans
+)
+
+log ""
+log "PI WEB Docker runtime is ready: http://$pi_web_bind_addr:$pi_web_port"
+log "Install directory: $install_dir"
+log "To update later, re-run this installer."
+(
+ cd "$install_dir"
+ compose_cmd -f compose.yml ps
+)
From fb36f48fea8630a7b1119b146bde52294bb1628a Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 22:18:15 +0200
Subject: [PATCH 04/20] feat(docker): add development compose setup
---
.changeset/docker-development-setup.md | 5 ++
.dockerignore | 9 +++
docker/Dockerfile.dev | 52 ++++++++++++++
docker/compose.dev.yml | 95 ++++++++++++++++++++++++++
4 files changed, 161 insertions(+)
create mode 100644 .changeset/docker-development-setup.md
create mode 100644 .dockerignore
create mode 100644 docker/Dockerfile.dev
create mode 100644 docker/compose.dev.yml
diff --git a/.changeset/docker-development-setup.md b/.changeset/docker-development-setup.md
new file mode 100644
index 0000000..cab3d24
--- /dev/null
+++ b/.changeset/docker-development-setup.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add a Docker development setup that builds from the local checkout while keeping the session daemon separate from autoreloading web/API/client services.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..34f60e9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+node_modules
+dist
+.pi-web
+.playwright-cli
+dev-plugins
+*.log
+.env
+.DS_Store
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
new file mode 100644
index 0000000..cdd5afc
--- /dev/null
+++ b/docker/Dockerfile.dev
@@ -0,0 +1,52 @@
+# syntax=docker/dockerfile:1.7
+
+ARG NODE_VERSION=22-bookworm-slim
+ARG DOCKER_CLI_VERSION=29-cli
+
+FROM docker:${DOCKER_CLI_VERSION} AS docker-cli
+
+FROM node:${NODE_VERSION} AS dev
+
+ENV NODE_ENV=development \
+ NPM_CONFIG_UPDATE_NOTIFIER=false \
+ NPM_CONFIG_CACHE=/data/npm-cache \
+ HOME=/data/home \
+ XDG_CONFIG_HOME=/data/config \
+ PI_WEB_DATA_DIR=/data/pi-web \
+ PI_WEB_SESSIOND_SOCKET=/data/pi-web/sessiond.sock \
+ PI_CODING_AGENT_DIR=/data/pi-agent \
+ HOSTEXEC_IMAGE=alpine:3.22 \
+ SHELL=/bin/bash \
+ TERM=xterm-256color
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ bash \
+ ca-certificates \
+ curl \
+ g++ \
+ git \
+ make \
+ openssh-client \
+ procps \
+ python3 \
+ tini \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /workspace
+
+COPY package.json package-lock.json ./
+COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs
+RUN npm ci \
+ && npm cache clean --force \
+ && mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent \
+ && chmod -R a+rwX /workspace/node_modules /data \
+ && chmod 0777 /workspace
+
+COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec
+
+EXPOSE 8504 8505
+
+ENTRYPOINT ["tini", "--"]
+CMD ["npm", "run", "dev"]
diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml
new file mode 100644
index 0000000..c85fe2a
--- /dev/null
+++ b/docker/compose.dev.yml
@@ -0,0 +1,95 @@
+name: pi-web-dev
+
+x-pi-web-dev-build: &pi-web-dev-build
+ context: ..
+ dockerfile: docker/Dockerfile.dev
+
+x-pi-web-dev-environment: &pi-web-dev-environment
+ HOME: /data/home
+ XDG_CONFIG_HOME: /data/config
+ PI_WEB_DATA_DIR: /data/pi-web
+ PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock
+ PI_CODING_AGENT_DIR: /data/pi-agent
+ HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
+ PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
+ NPM_CONFIG_UPDATE_NOTIFIER: "false"
+ NPM_CONFIG_CACHE: /data/npm-cache
+
+x-pi-web-dev-volumes: &pi-web-dev-volumes
+ - type: bind
+ source: ..
+ target: /workspace
+ - type: volume
+ source: node_modules
+ target: /workspace/node_modules
+ - type: volume
+ source: data
+ target: /data
+ - type: bind
+ source: /var/run/docker.sock
+ target: /var/run/docker.sock
+ - type: bind
+ source: /srv
+ target: /srv
+ - type: bind
+ source: /opt
+ target: /opt
+ - type: bind
+ source: /home
+ target: /home
+ - type: bind
+ source: /
+ target: /host
+ read_only: true
+
+services:
+ sessiond:
+ build: *pi-web-dev-build
+ image: ${PI_WEB_DEV_IMAGE:-pi-web:dev}
+ command: ["npm", "run", "start:sessiond"]
+ working_dir: /workspace
+ user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
+ group_add:
+ - "${DOCKER_GID:-0}"
+ security_opt:
+ - label=disable
+ environment: *pi-web-dev-environment
+ volumes: *pi-web-dev-volumes
+ healthcheck:
+ test: ["CMD-SHELL", "test -S /data/pi-web/sessiond.sock"]
+ interval: 5s
+ timeout: 3s
+ retries: 24
+ start_period: 5s
+
+ web:
+ build: *pi-web-dev-build
+ image: ${PI_WEB_DEV_IMAGE:-pi-web:dev}
+ command: ["bash", "-lc", "trap 'kill 0' EXIT; npm run dev:web & npm run dev:client & wait"]
+ working_dir: /workspace
+ depends_on:
+ sessiond:
+ condition: service_healthy
+ user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
+ group_add:
+ - "${DOCKER_GID:-0}"
+ security_opt:
+ - label=disable
+ environment:
+ <<: *pi-web-dev-environment
+ PI_WEB_HOST: 0.0.0.0
+ PI_WEB_PORT: "8504"
+ ports:
+ - "${PI_WEB_DEV_API_BIND_ADDR:-127.0.0.1}:${PI_WEB_DEV_API_PORT:-8504}:8504"
+ - "${PI_WEB_DEV_BIND_ADDR:-127.0.0.1}:${PI_WEB_DEV_PORT:-8505}:8505"
+ volumes: *pi-web-dev-volumes
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8504/api/pi-web/runtime >/dev/null"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 10s
+
+volumes:
+ data:
+ node_modules:
From b9b03d985857abf816bfd39844f4f59ffd7bee6a Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 22:24:56 +0200
Subject: [PATCH 05/20] docs(docker): document runtime and dev usage
---
.changeset/docker-usage-docs.md | 5 +
README.md | 23 ++++
docker/README.md | 233 ++++++++++++++++++++++++++++++++
3 files changed, 261 insertions(+)
create mode 100644 .changeset/docker-usage-docs.md
create mode 100644 docker/README.md
diff --git a/.changeset/docker-usage-docs.md b/.changeset/docker-usage-docs.md
new file mode 100644
index 0000000..5de2bcc
--- /dev/null
+++ b/.changeset/docker-usage-docs.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Document Docker runtime and development usage, including trust warnings, update/version pinning, localhost exposure, and host command examples.
diff --git a/README.md b/README.md
index 2ccad3c..3ba2682 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,18 @@ One-line install is also available for users who prefer it:
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh
```
+### Docker local-build runtime
+
+A Docker runtime is available for trusted local/server installs without using prebuilt images:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
+```
+
+It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
+
+See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, host command examples, and development Compose usage.
+
PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi:
```bash
@@ -234,6 +246,17 @@ pi-web install --dev
`dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions.
+Docker development from the checkout is available too:
+
+```bash
+export PI_WEB_UID=$(id -u)
+export PI_WEB_GID=$(id -g)
+export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
+docker compose -f docker/compose.dev.yml up --build
+```
+
+Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup).
+
## Production-style run from a checkout
```bash
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..f3440aa
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,233 @@
+# PI WEB Docker
+
+PI WEB has two Docker modes:
+
+- **Runtime/server mode** builds a local image from npm packages and runs split `sessiond` + `web` services. This is for users and servers.
+- **Development mode** builds from this checkout and runs the same split shape while letting the web/API/client services autoreload. This is for hacking on PI WEB.
+
+No prebuilt image or registry is required in either mode.
+
+## Trust model: read this first
+
+The Docker setup is for trusted single-user or trusted-admin environments. It is not a sandbox and it is not suitable for untrusted multi-tenant use.
+
+By design, the runtime containers get deliberate host access so PI WEB agents can work on real server paths:
+
+- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host.
+- `/srv`, `/opt`, and `/home` are mounted read/write.
+- `/` is mounted read-only at `/host` for inspection.
+- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces.
+
+Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository.
+
+The web port is bound to `127.0.0.1` by default. Do **not** expose PI WEB directly to the public internet. For remote access, use one of:
+
+- an SSH tunnel;
+- a VPN/private network address such as Tailscale, NetBird, or WireGuard;
+- an authenticated reverse proxy that you operate and trust.
+
+## Runtime install/update
+
+Prerequisites:
+
+- Docker Engine with the Compose plugin (`docker compose`) or `docker-compose`;
+- a user that can talk to the Docker daemon;
+- `curl` or `wget` for the one-liner installer.
+
+Install or update with the same command:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
+```
+
+The one-liner is idempotent. Each run refreshes Docker assets from the requested Git ref, writes host-specific `.env` values, rebuilds the local image from npm with `--pull --no-cache`, and recreates the split services without deleting persistent data.
+
+Defaults:
+
+- install directory: `~/.local/share/pi-web-docker` (or `$XDG_DATA_HOME/pi-web-docker`);
+- persistent data: `/data`, mounted at `/data`;
+- browser URL: ;
+- npm packages: latest `@jmfederico/pi-web` and latest Pi Coding Agent package unless pinned.
+
+Updating recreates the Docker `sessiond` container. Active Pi agent runtimes in this Docker install may stop, so update while sessions are idle. Persisted PI WEB state, Pi config, and session history under the data directory are kept.
+
+Useful runtime commands:
+
+```bash
+cd ~/.local/share/pi-web-docker
+
+docker compose ps
+docker compose logs -f web
+docker compose logs -f sessiond
+docker compose restart web
+docker compose restart sessiond
+```
+
+To stop the runtime without deleting data:
+
+```bash
+cd ~/.local/share/pi-web-docker
+docker compose down
+```
+
+Do not run `docker compose down -v` unless you intentionally want to remove Compose-managed volumes. The default persistent PI WEB data is a bind mount, but avoiding `-v` keeps the update/stop flow conservative.
+
+### Installer options
+
+The installer accepts flags and equivalent environment variables:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \
+ | sh -s -- \
+ --install-dir ~/.local/share/pi-web-docker \
+ --data-dir ~/.local/share/pi-web-docker/data \
+ --bind-address 127.0.0.1 \
+ --port 8504 \
+ --pi-web-version latest \
+ --pi-version latest
+```
+
+Common environment variables written to `.env`:
+
+| Variable | Purpose |
+| --- | --- |
+| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers |
+| `DOCKER_GID` | extra group used for Docker socket access |
+| `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount |
+| `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port |
+| `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` |
+| `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` |
+| `PI_WEB_IMAGE` | local image tag to build and run |
+| `HOSTEXEC_IMAGE` | helper image used by `hostexec` |
+
+Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
+
+### Version pinning
+
+Pin npm package versions when you want repeatable rebuilds:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \
+ | sh -s -- --pi-web-version 1.202606.4 --pi-version 0.79.1
+```
+
+You can also edit `.env` in the install directory:
+
+```dotenv
+PI_WEB_VERSION=1.202606.4
+PI_VERSION=0.79.1
+```
+
+Then rerun the one-liner to rebuild/recreate with those pins. Use `latest` again when you want the runtime to track the newest npm releases.
+
+To pin the Docker asset templates themselves, fetch the installer from a specific Git branch, tag, or commit and pass the same ref as the asset source:
+
+```bash
+ref=
+curl -fsSL "https://raw.githubusercontent.com/jmfederico/pi-web/$ref/docker/install.sh" \
+ | sh -s -- --asset-ref "$ref"
+```
+
+## Localhost binding and remote access
+
+The runtime listens on `0.0.0.0:8504` inside the container but publishes it to `127.0.0.1:8504` on the host by default.
+
+For SSH access from your laptop:
+
+```bash
+ssh -L 8504:127.0.0.1:8504 user@server
+# open http://127.0.0.1:8504 locally
+```
+
+For a trusted VPN/private interface, bind to that private address:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \
+ | sh -s -- --bind-address 100.x.y.z --port 8504
+```
+
+If you use a reverse proxy, keep the container bound to localhost or a private address and put authentication/TLS at the proxy. Avoid `--bind-address 0.0.0.0` unless another trusted layer restricts access.
+
+## `hostexec` examples
+
+`hostexec ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions.
+
+Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container:
+
+```bash
+hostexec uname -a
+hostexec systemctl status docker
+hostexec zypper refresh
+hostexec sh -lc 'zypper refresh && zypper dup -y'
+hostexec apt-get update
+```
+
+From the host shell, for a quick smoke test:
+
+```bash
+cd ~/.local/share/pi-web-docker
+docker compose exec web hostexec uname -a
+```
+
+`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like running a privileged host command.
+
+## Development Docker setup
+
+Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies and PI WEB data in Docker volumes, and preserves the split runtime model:
+
+- `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes;
+- `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`.
+
+From the repository root:
+
+```bash
+export PI_WEB_UID=$(id -u)
+export PI_WEB_GID=$(id -g)
+export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
+
+docker compose -f docker/compose.dev.yml up --build
+```
+
+Open the Vite UI at . The dev API is published on .
+
+Useful development commands:
+
+```bash
+docker compose -f docker/compose.dev.yml ps
+docker compose -f docker/compose.dev.yml logs -f web
+docker compose -f docker/compose.dev.yml restart web
+docker compose -f docker/compose.dev.yml restart sessiond
+docker compose -f docker/compose.dev.yml down
+```
+
+Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads.
+
+The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply.
+
+When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree:
+
+```bash
+docker compose -f docker/compose.dev.yml down
+docker volume rm pi-web-dev_node_modules
+docker compose -f docker/compose.dev.yml up --build
+```
+
+## Local checkout validation
+
+For installer validation from a checkout without starting containers:
+
+```bash
+PI_WEB_DOCKER_SKIP_COMPOSE=1 \
+PI_WEB_DOCKER_ASSET_DIR="$PWD/docker" \
+PI_WEB_DOCKER_HOME="$(mktemp -d)" \
+sh docker/install.sh
+```
+
+For Compose validation:
+
+```bash
+docker compose -f docker/compose.yml config
+docker compose -f docker/compose.dev.yml config
+docker build --check -f docker/Dockerfile docker
+docker build --check -f docker/Dockerfile.dev .
+```
From a0d60215ca98bf75a268f4a8908c75295886d628 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 23:28:35 +0200
Subject: [PATCH 06/20] feat(docker): share dev data with runtime
---
.changeset/docker-shared-dev-data.md | 5 +++++
README.md | 2 +-
docker/README.md | 32 +++++++++++++++++++++++++++-
docker/compose.dev.yml | 5 ++---
4 files changed, 39 insertions(+), 5 deletions(-)
create mode 100644 .changeset/docker-shared-dev-data.md
diff --git a/.changeset/docker-shared-dev-data.md b/.changeset/docker-shared-dev-data.md
new file mode 100644
index 0000000..3ab67da
--- /dev/null
+++ b/.changeset/docker-shared-dev-data.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Share the Docker development data mount with the runtime Docker data directory by default so Pi sessions can be reused across modes.
diff --git a/README.md b/README.md
index 3ba2682..61ad3ba 100644
--- a/README.md
+++ b/README.md
@@ -255,7 +255,7 @@ export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
docker compose -f docker/compose.dev.yml up --build
```
-Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup).
+Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service and uses the runtime Docker data directory by default so sessions can be shared across modes. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup).
## Production-style run from a checkout
diff --git a/docker/README.md b/docker/README.md
index f3440aa..2eab487 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -173,7 +173,7 @@ docker compose exec web hostexec uname -a
## Development Docker setup
-Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies and PI WEB data in Docker volumes, and preserves the split runtime model:
+Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies in a Docker volume, stores PI WEB/Pi data in the same host data directory as runtime mode by default, and preserves the split runtime model:
- `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes;
- `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`.
@@ -184,10 +184,20 @@ From the repository root:
export PI_WEB_UID=$(id -u)
export PI_WEB_GID=$(id -g)
export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
+# Optional; this is also the default dev data path.
+export PI_WEB_DOCKER_DATA_DIR=${PI_WEB_DOCKER_DATA_DIR:-$HOME/.local/share/pi-web-docker/data}
+mkdir -p "$PI_WEB_DOCKER_DATA_DIR"
docker compose -f docker/compose.dev.yml up --build
```
+If you already ran the runtime installer, you can reuse its `.env` so dev mode gets the same UID/GID, Docker group, ports, and data directory:
+
+```bash
+docker compose --env-file "$HOME/.local/share/pi-web-docker/.env" \
+ -f docker/compose.dev.yml up --build
+```
+
Open the Vite UI at . The dev API is published on .
Useful development commands:
@@ -204,6 +214,26 @@ Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`,
The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply.
+### Sharing runtime and development state
+
+Runtime and dev mode both use `/data` inside the containers. By default they now point at the same host directory:
+
+```text
+$HOME/.local/share/pi-web-docker/data
+```
+
+Pi session files are therefore shared at:
+
+```text
+$HOME/.local/share/pi-web-docker/data/pi-agent/sessions/
+```
+
+Set `PI_WEB_DOCKER_DATA_DIR=/some/path` for both modes if you want that shared data somewhere else.
+
+Use this shared directory to switch between runtime and dev mode, not to run both at the same time. Stop one Compose stack before starting the other so two session daemons do not share the same socket/state directory concurrently.
+
+For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Flatcar, prefer host-mounted paths such as `/home/core/`, `/srv/`, or `/opt/`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for `/home/core/`.
+
When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree:
```bash
diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml
index c85fe2a..b5359dc 100644
--- a/docker/compose.dev.yml
+++ b/docker/compose.dev.yml
@@ -22,8 +22,8 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes
- type: volume
source: node_modules
target: /workspace/node_modules
- - type: volume
- source: data
+ - type: bind
+ source: ${PI_WEB_DOCKER_DATA_DIR:-${HOME}/.local/share/pi-web-docker/data}
target: /data
- type: bind
source: /var/run/docker.sock
@@ -91,5 +91,4 @@ services:
start_period: 10s
volumes:
- data:
node_modules:
From 3e52939aade4333a4fbbc7fdc566e8ee777dd19c Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Sat, 20 Jun 2026 23:45:04 +0200
Subject: [PATCH 07/20] fix(docker): initialize dev data permissions
---
.changeset/docker-dev-data-permissions.md | 5 ++++
docker/README.md | 2 ++
docker/compose.dev.yml | 31 ++++++++++++++++++++---
3 files changed, 35 insertions(+), 3 deletions(-)
create mode 100644 .changeset/docker-dev-data-permissions.md
diff --git a/.changeset/docker-dev-data-permissions.md b/.changeset/docker-dev-data-permissions.md
new file mode 100644
index 0000000..6a0b7ea
--- /dev/null
+++ b/.changeset/docker-dev-data-permissions.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Initialize shared Docker development data directory ownership before starting the dev session daemon.
diff --git a/docker/README.md b/docker/README.md
index 2eab487..68e1f4b 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -214,6 +214,8 @@ Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`,
The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply.
+On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon.
+
### Sharing runtime and development state
Runtime and dev mode both use `/data` inside the containers. By default they now point at the same host directory:
diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml
index b5359dc..f96cdf7 100644
--- a/docker/compose.dev.yml
+++ b/docker/compose.dev.yml
@@ -15,6 +15,11 @@ x-pi-web-dev-environment: &pi-web-dev-environment
NPM_CONFIG_UPDATE_NOTIFIER: "false"
NPM_CONFIG_CACHE: /data/npm-cache
+x-pi-web-dev-data-volume: &pi-web-dev-data-volume
+ type: bind
+ source: ${PI_WEB_DOCKER_DATA_DIR:-${HOME}/.local/share/pi-web-docker/data}
+ target: /data
+
x-pi-web-dev-volumes: &pi-web-dev-volumes
- type: bind
source: ..
@@ -22,9 +27,7 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes
- type: volume
source: node_modules
target: /workspace/node_modules
- - type: bind
- source: ${PI_WEB_DOCKER_DATA_DIR:-${HOME}/.local/share/pi-web-docker/data}
- target: /data
+ - *pi-web-dev-data-volume
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
@@ -43,11 +46,33 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes
read_only: true
services:
+ data-init:
+ build: *pi-web-dev-build
+ image: ${PI_WEB_DEV_IMAGE:-pi-web:dev}
+ command:
+ - bash
+ - -lc
+ - |
+ set -euo pipefail
+ mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent
+ chown -R "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" /data
+ user: "0:0"
+ security_opt:
+ - label=disable
+ environment:
+ PI_WEB_UID: ${PI_WEB_UID:-1000}
+ PI_WEB_GID: ${PI_WEB_GID:-1000}
+ volumes:
+ - *pi-web-dev-data-volume
+
sessiond:
build: *pi-web-dev-build
image: ${PI_WEB_DEV_IMAGE:-pi-web:dev}
command: ["npm", "run", "start:sessiond"]
working_dir: /workspace
+ depends_on:
+ data-init:
+ condition: service_completed_successfully
user: "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}"
group_add:
- "${DOCKER_GID:-0}"
From 92e87ff1e45007f5e601e8f629fce77077dad5e5 Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Sun, 21 Jun 2026 17:40:45 +0000
Subject: [PATCH 08/20] fix(docker): run hostexec as container user
---
.changeset/hostexec-container-user.md | 5 ++
docker/README.md | 12 ++--
docker/bin/hostexec | 95 ++++++++++++++++++++++++---
3 files changed, 96 insertions(+), 16 deletions(-)
create mode 100644 .changeset/hostexec-container-user.md
diff --git a/.changeset/hostexec-container-user.md b/.changeset/hostexec-container-user.md
new file mode 100644
index 0000000..6302643
--- /dev/null
+++ b/.changeset/hostexec-container-user.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Run Docker host command bridge commands as the PI WEB container user by default, with `hostexec --root` for administrative commands.
diff --git a/docker/README.md b/docker/README.md
index 68e1f4b..f66606f 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -16,7 +16,7 @@ By design, the runtime containers get deliberate host access so PI WEB agents ca
- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host.
- `/srv`, `/opt`, and `/home` are mounted read/write.
- `/` is mounted read-only at `/host` for inspection.
-- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces.
+- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. Commands run as the container user by default, and `hostexec --root` can still run explicit administrative commands as root.
Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository.
@@ -150,16 +150,16 @@ If you use a reverse proxy, keep the container bound to localhost or a private a
## `hostexec` examples
-`hostexec ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions.
+`hostexec [--root] ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands.
Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container:
```bash
hostexec uname -a
hostexec systemctl status docker
-hostexec zypper refresh
-hostexec sh -lc 'zypper refresh && zypper dup -y'
-hostexec apt-get update
+hostexec --root zypper refresh
+hostexec --root sh -lc 'zypper refresh && zypper dup -y'
+hostexec --root apt-get update
```
From the host shell, for a quick smoke test:
@@ -169,7 +169,7 @@ cd ~/.local/share/pi-web-docker
docker compose exec web hostexec uname -a
```
-`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like running a privileged host command.
+`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user.
## Development Docker setup
diff --git a/docker/bin/hostexec b/docker/bin/hostexec
index f70cc61..c46a3d0 100755
--- a/docker/bin/hostexec
+++ b/docker/bin/hostexec
@@ -3,13 +3,36 @@ set -euo pipefail
usage() {
cat >&2 <<'EOF'
-Usage: hostexec
+Usage: hostexec [--root] [--]
Run a command on the Docker host by starting a temporary privileged helper
container through the mounted Docker socket and entering the host namespaces.
+Commands run as the current container UID/GID by default. Use --root to keep
+root privileges for administrative host commands.
EOF
}
+run_as_root=false
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --root)
+ run_as_root=true
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
if [ "$#" -eq 0 ]; then
usage
exit 64
@@ -30,19 +53,71 @@ if [[ "$docker_host" == unix://* ]]; then
fi
helper_image="${HOSTEXEC_IMAGE:-alpine:3.22}"
+target_uid="$(id -u)"
+target_gid="$(id -g)"
tty_args=(--interactive)
if [ -t 0 ] && [ -t 1 ]; then
tty_args+=(--tty)
fi
+docker_args=(
+ --rm
+ "${tty_args[@]}"
+ --pull=missing
+ --privileged
+ --security-opt label=disable
+ --pid=host
+ --network=host
+ --volume /:/host:rw
+)
+
+if [ "$run_as_root" = true ] || { [ "$target_uid" = 0 ] && [ "$target_gid" = 0 ]; }; then
+ exec docker run \
+ "${docker_args[@]}" \
+ "$helper_image" \
+ nsenter -t 1 -m -u -i -n -p -- "$@"
+fi
+
+run_as_container_user='target_uid="${HOSTEXEC_TARGET_UID:?}"
+target_gid="${HOSTEXEC_TARGET_GID:?}"
+
+target_user=""
+if command -v getent >/dev/null 2>&1; then
+ passwd_entry="$(getent passwd "$target_uid" || true)"
+ if [ -n "$passwd_entry" ]; then
+ target_user="${passwd_entry%%:*}"
+ fi
+fi
+
+if [ -n "$target_user" ]; then
+ if command -v runuser >/dev/null 2>&1; then
+ exec runuser -u "$target_user" -- "$@"
+ fi
+
+ if command -v su >/dev/null 2>&1; then
+ exec su -s /bin/sh -c '\''exec "$@"'\'' -- "$target_user" hostexec-su "$@"
+ fi
+fi
+
+if command -v setpriv >/dev/null 2>&1; then
+ if [ -n "$target_user" ]; then
+ exec setpriv --reuid "$target_uid" --regid "$target_gid" --init-groups -- "$@"
+ fi
+
+ exec setpriv --reuid "$target_uid" --regid "$target_gid" --clear-groups -- "$@"
+fi
+
+if command -v nsenter >/dev/null 2>&1; then
+ exec nsenter -t 1 -m -u -i -n -p -S "$target_uid" -G "$target_gid" -- "$@"
+fi
+
+echo "hostexec: unable to switch to host uid:gid $target_uid:$target_gid" >&2
+exit 69
+'
+
exec docker run \
- --rm \
- "${tty_args[@]}" \
- --pull=missing \
- --privileged \
- --security-opt label=disable \
- --pid=host \
- --network=host \
- --volume /:/host:rw \
+ "${docker_args[@]}" \
+ --env HOSTEXEC_TARGET_UID="$target_uid" \
+ --env HOSTEXEC_TARGET_GID="$target_gid" \
"$helper_image" \
- nsenter -t 1 -m -u -i -n -p -- "$@"
+ nsenter -t 1 -m -u -i -n -p -- /bin/sh -c "$run_as_container_user" hostexec-user "$@"
From 784a27a7acc6450bc5646ddf5bb06767b7c5996f Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Sun, 21 Jun 2026 17:41:27 +0000
Subject: [PATCH 09/20] feat(docker): add custom image hooks
---
.changeset/docker-custom-image-hooks.md | 5 +++++
.dockerignore | 4 ++++
.gitignore | 4 ++++
README.md | 2 +-
docker/.dockerignore | 3 +++
docker/Dockerfile | 10 +++++++++
docker/Dockerfile.dev | 10 +++++++++
docker/README.md | 28 +++++++++++++++++++++++++
docker/custom-image.d/.gitkeep | 0
docker/install.sh | 7 +++++++
10 files changed, 72 insertions(+), 1 deletion(-)
create mode 100644 .changeset/docker-custom-image-hooks.md
create mode 100644 docker/custom-image.d/.gitkeep
diff --git a/.changeset/docker-custom-image-hooks.md b/.changeset/docker-custom-image-hooks.md
new file mode 100644
index 0000000..bda892a
--- /dev/null
+++ b/.changeset/docker-custom-image-hooks.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add Docker custom image hooks so local installs can add optional CLIs without bloating the default image.
diff --git a/.dockerignore b/.dockerignore
index 34f60e9..db14171 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,3 +7,7 @@ dev-plugins
*.log
.env
.DS_Store
+
+docker/custom-image.d/*
+!docker/custom-image.d/.gitkeep
+!docker/custom-image.d/*.sh
diff --git a/.gitignore b/.gitignore
index 052ea5e..afb4be8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,9 @@ dist/
# Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/.
/dev-plugins/
+# Local Docker image build hooks for development containers.
+/docker/custom-image.d/*
+!/docker/custom-image.d/.gitkeep
+
# Local runtime attachment uploads (created by the chat composer "save to folder" mode).
.pi-web/
diff --git a/README.md b/README.md
index 61ad3ba..b924b0c 100644
--- a/README.md
+++ b/README.md
@@ -189,7 +189,7 @@ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/insta
It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
-See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, host command examples, and development Compose usage.
+See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, custom image hooks for optional CLIs, host command examples, and development Compose usage.
PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi:
diff --git a/docker/.dockerignore b/docker/.dockerignore
index 1bb8da0..872b011 100644
--- a/docker/.dockerignore
+++ b/docker/.dockerignore
@@ -3,3 +3,6 @@
!Dockerfile
!bin/
!bin/hostexec
+!custom-image.d/
+!custom-image.d/.gitkeep
+!custom-image.d/*.sh
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 787aad0..02bde6d 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -52,6 +52,16 @@ RUN apt-get update \
&& mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \
&& chown -R node:node /data /workspace
+COPY custom-image.d/ /tmp/pi-web-custom-image.d/
+RUN bash -euxo pipefail -c '\
+ shopt -s nullglob; \
+ for script in /tmp/pi-web-custom-image.d/*.sh; do \
+ echo "Running PI WEB custom image hook: ${script}"; \
+ bash "${script}"; \
+ done; \
+ rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \
+'
+
COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=package /usr/local/bin /usr/local/bin
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index cdd5afc..bcdcd31 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -46,6 +46,16 @@ RUN npm ci \
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec
+COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/
+RUN bash -euxo pipefail -c '\
+ shopt -s nullglob; \
+ for script in /tmp/pi-web-custom-image.d/*.sh; do \
+ echo "Running PI WEB custom image hook: ${script}"; \
+ bash "${script}"; \
+ done; \
+ rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \
+'
+
EXPOSE 8504 8505
ENTRYPOINT ["tini", "--"]
diff --git a/docker/README.md b/docker/README.md
index f66606f..419128a 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -102,6 +102,34 @@ Common environment variables written to `.env`:
Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
+### Custom image hooks
+
+The runtime image can be extended without changing PI WEB's Dockerfile. Put local Bash scripts ending in `.sh` under:
+
+```text
+~/.local/share/pi-web-docker/custom-image.d/
+```
+
+The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `gh`, `glab`, `kubectl`, or cloud CLIs that you do not want in the default image.
+
+Example:
+
+```bash
+mkdir -p ~/.local/share/pi-web-docker/custom-image.d
+$EDITOR ~/.local/share/pi-web-docker/custom-image.d/10-github-cli.sh
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
+```
+
+Keep credentials out of these scripts. Authenticate tools after the container starts so secrets live in the persistent `/data` mount, for example through `/data/home` and `/data/config`.
+
+For Docker development from this checkout, use the equivalent local directory:
+
+```text
+docker/custom-image.d/
+```
+
+Files in that development hook directory are ignored by Git except for the placeholder that keeps the directory available to Docker builds.
+
### Version pinning
Pin npm package versions when you want repeatable rebuilds:
diff --git a/docker/custom-image.d/.gitkeep b/docker/custom-image.d/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docker/install.sh b/docker/install.sh
index 6800042..27fc2fb 100755
--- a/docker/install.sh
+++ b/docker/install.sh
@@ -306,6 +306,12 @@ write_asset .dockerignore 0644
write_asset install.sh 0755
write_asset bin/hostexec 0755
+custom_image_hooks_dir=$install_dir/custom-image.d
+mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir"
+if [ ! -e "$custom_image_hooks_dir/.gitkeep" ]; then
+ : >"$custom_image_hooks_dir/.gitkeep" || die "could not initialize custom image hooks directory: $custom_image_hooks_dir"
+fi
+
pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)")
pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)")
docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)")
@@ -364,6 +370,7 @@ mv "$temp_env" "$env_file"
log "Wrote Docker assets to $install_dir"
log "Wrote runtime environment to $env_file"
log "Persistent PI WEB Docker data: $data_dir"
+log "Custom image hooks: $custom_image_hooks_dir"
if [ "${PI_WEB_DOCKER_SKIP_COMPOSE:-0}" = 1 ]; then
log "Skipping Docker build/recreate because PI_WEB_DOCKER_SKIP_COMPOSE=1"
From f8982a9947b106ddf68ed56bc944f75d1f859971 Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Sun, 21 Jun 2026 22:17:30 +0000
Subject: [PATCH 10/20] feat(docker): migrate images to openSUSE Tumbleweed
---
.changeset/opensuse-tumbleweed-docker.md | 5 +
README.md | 4 +-
docker/.dockerignore | 1 +
docker/Dockerfile | 54 ++++-----
docker/Dockerfile.dev | 36 +++---
docker/README.md | 30 ++++-
docker/bin/install-opensuse-base | 135 +++++++++++++++++++++++
docker/compose.dev.yml | 5 +
docker/compose.yml | 4 +
docker/install.sh | 48 ++++++++
10 files changed, 272 insertions(+), 50 deletions(-)
create mode 100644 .changeset/opensuse-tumbleweed-docker.md
create mode 100755 docker/bin/install-opensuse-base
diff --git a/.changeset/opensuse-tumbleweed-docker.md b/.changeset/opensuse-tumbleweed-docker.md
new file mode 100644
index 0000000..387bb50
--- /dev/null
+++ b/.changeset/opensuse-tumbleweed-docker.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Move the Docker runtime and development images to openSUSE Tumbleweed with Node.js 22, npx, Corepack, and common development tooling, plus zypper-based package customization.
diff --git a/README.md b/README.md
index b924b0c..3e7dda5 100644
--- a/README.md
+++ b/README.md
@@ -187,9 +187,9 @@ A Docker runtime is available for trusted local/server installs without using pr
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
```
-It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
+It builds an openSUSE Tumbleweed based local image from npm with Node.js 22, npx, Corepack, and common development/agent tooling, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
-See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, custom image hooks for optional CLIs, host command examples, and development Compose usage.
+See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, openSUSE package customization, custom image hooks for optional CLIs, host command examples, and development Compose usage.
PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi:
diff --git a/docker/.dockerignore b/docker/.dockerignore
index 872b011..8a7307a 100644
--- a/docker/.dockerignore
+++ b/docker/.dockerignore
@@ -3,6 +3,7 @@
!Dockerfile
!bin/
!bin/hostexec
+!bin/install-opensuse-base
!custom-image.d/
!custom-image.d/.gitkeep
!custom-image.d/*.sh
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 02bde6d..c411301 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,22 +1,32 @@
# syntax=docker/dockerfile:1.7
-ARG NODE_VERSION=22-bookworm-slim
+ARG OPENSUSE_IMAGE=opensuse/tumbleweed
ARG DOCKER_CLI_VERSION=29-cli
FROM docker:${DOCKER_CLI_VERSION} AS docker-cli
-FROM node:${NODE_VERSION} AS package
+FROM ${OPENSUSE_IMAGE} AS base
+
+ARG NODEJS_MAJOR=22
+ARG NODEJS_REPO=auto
+ARG PI_WEB_EXTRA_ZYPPER_PACKAGES=""
+
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+
+ENV NPM_CONFIG_UPDATE_NOTIFIER=false \
+ SHELL=/bin/bash \
+ TERM=xterm-256color
+
+COPY bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
+RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \
+ && install-pi-web-opensuse-base
+
+FROM base AS package
ARG PI_WEB_VERSION=latest
ARG PI_VERSION=latest
ARG CACHE_BUST=local
-ENV NPM_CONFIG_UPDATE_NOTIFIER=false
-
-RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates g++ make python3 \
- && rm -rf /var/lib/apt/lists/*
-
RUN set -eux; \
echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \
npm install -g --omit=dev --no-audit --no-fund \
@@ -24,7 +34,7 @@ RUN set -eux; \
"@earendil-works/pi-coding-agent@${PI_VERSION}"; \
npm cache clean --force
-FROM node:${NODE_VERSION} AS runtime
+FROM base AS runtime
ENV NODE_ENV=production \
NPM_CONFIG_UPDATE_NOTIFIER=false \
@@ -39,18 +49,11 @@ ENV NODE_ENV=production \
SHELL=/bin/bash \
TERM=xterm-256color
-RUN apt-get update \
- && apt-get install -y --no-install-recommends \
- bash \
- ca-certificates \
- curl \
- git \
- openssh-client \
- procps \
- tini \
- && rm -rf /var/lib/apt/lists/* \
- && mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \
- && chown -R node:node /data /workspace
+COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
+COPY --from=package /usr/local/bin /usr/local/bin
+COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+COPY bin/hostexec /usr/local/bin/hostexec
+RUN chmod 0755 /usr/local/bin/hostexec
COPY custom-image.d/ /tmp/pi-web-custom-image.d/
RUN bash -euxo pipefail -c '\
@@ -59,14 +62,11 @@ RUN bash -euxo pipefail -c '\
echo "Running PI WEB custom image hook: ${script}"; \
bash "${script}"; \
done; \
- rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \
+ rm -rf /tmp/pi-web-custom-image.d; \
+ zypper clean --all; \
+ rm -rf /var/cache/zypp/* \
'
-COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
-COPY --from=package /usr/local/bin /usr/local/bin
-COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
-COPY --chmod=0755 bin/hostexec /usr/local/bin/hostexec
-
WORKDIR /workspace
USER node
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index bcdcd31..a1a9608 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -1,13 +1,20 @@
# syntax=docker/dockerfile:1.7
-ARG NODE_VERSION=22-bookworm-slim
+ARG OPENSUSE_IMAGE=opensuse/tumbleweed
ARG DOCKER_CLI_VERSION=29-cli
FROM docker:${DOCKER_CLI_VERSION} AS docker-cli
-FROM node:${NODE_VERSION} AS dev
+FROM ${OPENSUSE_IMAGE} AS dev
+
+ARG NODEJS_MAJOR=22
+ARG NODEJS_REPO=auto
+ARG PI_WEB_EXTRA_ZYPPER_PACKAGES=""
+
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV NODE_ENV=development \
+ PATH=/workspace/node_modules/.bin:$PATH \
NPM_CONFIG_UPDATE_NOTIFIER=false \
NPM_CONFIG_CACHE=/data/npm-cache \
HOME=/data/home \
@@ -19,32 +26,23 @@ ENV NODE_ENV=development \
SHELL=/bin/bash \
TERM=xterm-256color
-RUN apt-get update \
- && apt-get install -y --no-install-recommends \
- bash \
- ca-certificates \
- curl \
- g++ \
- git \
- make \
- openssh-client \
- procps \
- python3 \
- tini \
- && rm -rf /var/lib/apt/lists/*
+COPY docker/bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
+RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \
+ && install-pi-web-opensuse-base
WORKDIR /workspace
COPY package.json package-lock.json ./
COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs
RUN npm ci \
+ && ln -sf /workspace/node_modules/.bin/pi /usr/local/bin/pi \
&& npm cache clean --force \
- && mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent \
&& chmod -R a+rwX /workspace/node_modules /data \
&& chmod 0777 /workspace
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
-COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec
+COPY docker/bin/hostexec /usr/local/bin/hostexec
+RUN chmod 0755 /usr/local/bin/hostexec
COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/
RUN bash -euxo pipefail -c '\
@@ -53,7 +51,9 @@ RUN bash -euxo pipefail -c '\
echo "Running PI WEB custom image hook: ${script}"; \
bash "${script}"; \
done; \
- rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \
+ rm -rf /tmp/pi-web-custom-image.d; \
+ zypper clean --all; \
+ rm -rf /var/cache/zypp/* \
'
EXPOSE 8504 8505
diff --git a/docker/README.md b/docker/README.md
index 419128a..d9bfb74 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -97,10 +97,27 @@ Common environment variables written to `.env`:
| `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port |
| `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` |
| `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` |
+| `PI_WEB_OPENSUSE_IMAGE` | openSUSE base image used for the runtime build |
+| `PI_WEB_NODEJS_MAJOR` | Node.js major package to install, defaulting to `22` |
+| `PI_WEB_NODEJS_REPO` | Node.js zypper repository URL, `auto`, or `disabled` |
+| `PI_WEB_EXTRA_ZYPPER_PACKAGES` | extra openSUSE packages installed during the image build |
| `PI_WEB_IMAGE` | local image tag to build and run |
| `HOSTEXEC_IMAGE` | helper image used by `hostexec` |
-Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
+Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
+
+### Base image and tooling
+
+The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI.
+
+Install extra distro packages without writing a hook by setting a whitespace-delimited package list:
+
+```bash
+PI_WEB_EXTRA_ZYPPER_PACKAGES="go rustup kubernetes-client" \
+ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
+```
+
+You can also pass installer flags such as `--opensuse-image`, `--nodejs-major`, `--nodejs-repo`, and `--extra-zypper-packages`, or edit the generated `.env` and rerun the installer.
### Custom image hooks
@@ -110,13 +127,20 @@ The runtime image can be extended without changing PI WEB's Dockerfile. Put loca
~/.local/share/pi-web-docker/custom-image.d/
```
-The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `gh`, `glab`, `kubectl`, or cloud CLIs that you do not want in the default image.
+The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `glab`, `kubectl`, cloud CLIs, or language toolchains that you do not want in the default image.
Example:
```bash
mkdir -p ~/.local/share/pi-web-docker/custom-image.d
-$EDITOR ~/.local/share/pi-web-docker/custom-image.d/10-github-cli.sh
+cat >~/.local/share/pi-web-docker/custom-image.d/10-extra-tools.sh <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+zypper --gpg-auto-import-keys --non-interactive refresh
+zypper --non-interactive install --no-recommends glab kubernetes-client
+zypper clean --all
+EOF
+chmod +x ~/.local/share/pi-web-docker/custom-image.d/10-extra-tools.sh
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
```
diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base
new file mode 100755
index 0000000..7e0adfe
--- /dev/null
+++ b/docker/bin/install-opensuse-base
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+nodejs_major=${NODEJS_MAJOR:-22}
+nodejs_repo=${NODEJS_REPO:-auto}
+extra_zypper_packages=${PI_WEB_EXTRA_ZYPPER_PACKAGES:-}
+
+nodejs_repo_flavor() {
+ local rpm_arch
+ rpm_arch=$(rpm --eval '%{_target_cpu}')
+
+ case "$rpm_arch" in
+ aarch64|armv6hl|armv7hl)
+ printf '%s\n' openSUSE_Factory_ARM
+ ;;
+ ppc64le)
+ printf '%s\n' openSUSE_Factory_PowerPC
+ ;;
+ riscv64)
+ printf '%s\n' openSUSE_Factory_RISCV
+ ;;
+ s390x)
+ printf '%s\n' openSUSE_Factory_zSystems
+ ;;
+ *)
+ printf '%s\n' openSUSE_Tumbleweed
+ ;;
+ esac
+}
+
+add_nodejs_repo() {
+ local repo_url
+
+ case "$nodejs_repo" in
+ ""|disabled|none)
+ return 0
+ ;;
+ auto)
+ repo_url="https://download.opensuse.org/repositories/devel:/languages:/nodejs/$(nodejs_repo_flavor)/"
+ ;;
+ *)
+ repo_url=$nodejs_repo
+ ;;
+ esac
+
+ zypper --non-interactive removerepo pi-web-nodejs >/dev/null 2>&1 || true
+ zypper --non-interactive addrepo --refresh "$repo_url" pi-web-nodejs
+}
+
+# The codec repository is not needed for this image and can make noninteractive
+# refreshes noisy or brittle when its signing key rolls independently.
+zypper --non-interactive modifyrepo --disable repo-openh264 >/dev/null 2>&1 || true
+
+add_nodejs_repo
+zypper --gpg-auto-import-keys --non-interactive refresh
+
+packages=(
+ "nodejs${nodejs_major}"
+ "npm${nodejs_major}"
+ "corepack${nodejs_major}"
+ "nodejs${nodejs_major}-devel"
+ bash
+ ca-certificates
+ curl
+ wget
+ git
+ git-lfs
+ gh
+ openssh-clients
+ procps
+ tini
+ shadow
+ gcc-c++
+ make
+ python3
+ python3-devel
+ python3-pip
+ python3-virtualenv
+ jq
+ ripgrep
+ fd
+ fzf
+ bat
+ ShellCheck
+ less
+ file
+ which
+ tar
+ gzip
+ xz
+ unzip
+ zip
+ zstd
+ findutils
+ grep
+ sed
+ gawk
+ patch
+ diffutils
+ util-linux
+ hostname
+ iproute2
+ bind-utils
+ rsync
+)
+
+extra_packages=()
+if [ -n "$extra_zypper_packages" ]; then
+ # Intentionally split a whitespace-delimited package list supplied as a Docker
+ # build arg, e.g. PI_WEB_EXTRA_ZYPPER_PACKAGES="go rustup kubernetes-client".
+ # shellcheck disable=SC2206
+ extra_packages=($extra_zypper_packages)
+fi
+
+zypper --non-interactive install --no-recommends "${packages[@]}" "${extra_packages[@]}"
+
+node --version
+npm --version
+npx --version
+python3 --version
+git --version
+
+if ! getent group node >/dev/null 2>&1; then
+ groupadd --gid 1000 node
+fi
+
+if ! id node >/dev/null 2>&1; then
+ useradd --uid 1000 --gid node --create-home --home-dir /home/node --shell /bin/bash node
+fi
+
+mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace
+chown -R node:node /data /workspace /home/node
+
+zypper clean --all
+rm -rf /var/cache/zypp/*
diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml
index f96cdf7..cf582de 100644
--- a/docker/compose.dev.yml
+++ b/docker/compose.dev.yml
@@ -3,6 +3,11 @@ name: pi-web-dev
x-pi-web-dev-build: &pi-web-dev-build
context: ..
dockerfile: docker/Dockerfile.dev
+ args:
+ OPENSUSE_IMAGE: ${PI_WEB_OPENSUSE_IMAGE:-opensuse/tumbleweed}
+ NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22}
+ NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto}
+ PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-}
x-pi-web-dev-environment: &pi-web-dev-environment
HOME: /data/home
diff --git a/docker/compose.yml b/docker/compose.yml
index b98ac1c..b35a6bd 100644
--- a/docker/compose.yml
+++ b/docker/compose.yml
@@ -4,6 +4,10 @@ x-pi-web-build: &pi-web-build
context: .
dockerfile: Dockerfile
args:
+ OPENSUSE_IMAGE: ${PI_WEB_OPENSUSE_IMAGE:-opensuse/tumbleweed}
+ NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22}
+ NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto}
+ PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-}
PI_WEB_VERSION: ${PI_WEB_VERSION:-latest}
PI_VERSION: ${PI_VERSION:-latest}
CACHE_BUST: ${CACHE_BUST:-local}
diff --git a/docker/install.sh b/docker/install.sh
index 27fc2fb..5e26fda 100755
--- a/docker/install.sh
+++ b/docker/install.sh
@@ -28,6 +28,12 @@ Options:
--pi-web-version VER npm @jmfederico/pi-web version pin (default: latest)
--pi-version VER npm @earendil-works/pi-coding-agent version pin
(default: latest)
+ --opensuse-image IMAGE openSUSE base image (default: opensuse/tumbleweed)
+ --nodejs-major MAJOR Node.js major version package to install (default: 22)
+ --nodejs-repo REPO Node.js zypper repository URL, auto, or disabled
+ (default: auto)
+ --extra-zypper-packages LIST
+ extra openSUSE packages to install during image build
--asset-dir DIR Copy Docker assets from a local docker/ directory
--asset-ref REF Fetch Docker assets from a Git ref (default: main)
--skip-compose Write assets/.env but skip build and service recreate
@@ -72,6 +78,26 @@ while [ "$#" -gt 0 ]; do
PI_VERSION=$2
shift 2
;;
+ --opensuse-image)
+ [ "$#" -ge 2 ] || die "--opensuse-image requires a value"
+ PI_WEB_OPENSUSE_IMAGE=$2
+ shift 2
+ ;;
+ --nodejs-major)
+ [ "$#" -ge 2 ] || die "--nodejs-major requires a value"
+ PI_WEB_NODEJS_MAJOR=$2
+ shift 2
+ ;;
+ --nodejs-repo)
+ [ "$#" -ge 2 ] || die "--nodejs-repo requires a value"
+ PI_WEB_NODEJS_REPO=$2
+ shift 2
+ ;;
+ --extra-zypper-packages)
+ [ "$#" -ge 2 ] || die "--extra-zypper-packages requires a value"
+ PI_WEB_EXTRA_ZYPPER_PACKAGES=$2
+ shift 2
+ ;;
--asset-dir)
[ "$#" -ge 2 ] || die "--asset-dir requires a value"
PI_WEB_DOCKER_ASSET_DIR=$2
@@ -191,6 +217,12 @@ require_non_empty() {
[ -n "$value" ] || die "$name must not be empty"
}
+dotenv_quote() {
+ value=$1
+ [ -n "$value" ] || return 0
+ printf '"%s"' "$(printf '%s' "$value" | sed 's/[\\"]/\\&/g')"
+}
+
detect_docker_gid() {
if [ -S /var/run/docker.sock ]; then
if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then
@@ -305,6 +337,7 @@ write_asset compose.yml 0644
write_asset .dockerignore 0644
write_asset install.sh 0755
write_asset bin/hostexec 0755
+write_asset bin/install-opensuse-base 0755
custom_image_hooks_dir=$install_dir/custom-image.d
mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir"
@@ -323,6 +356,10 @@ pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0.
pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504)
pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest)
pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest)
+pi_web_opensuse_image=$(value_from_env_or_existing_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed)
+pi_web_nodejs_major=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_MAJOR 22)
+pi_web_nodejs_repo=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_REPO auto)
+pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local)
hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22)
pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
@@ -335,10 +372,15 @@ require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr"
require_non_empty PI_WEB_PORT "$pi_web_port"
require_non_empty PI_WEB_VERSION "$pi_web_version"
require_non_empty PI_VERSION "$pi_version"
+require_non_empty PI_WEB_OPENSUSE_IMAGE "$pi_web_opensuse_image"
+require_non_empty PI_WEB_NODEJS_MAJOR "$pi_web_nodejs_major"
+require_non_empty PI_WEB_NODEJS_REPO "$pi_web_nodejs_repo"
require_non_empty PI_WEB_IMAGE "$pi_web_image"
require_non_empty HOSTEXEC_IMAGE "$hostexec_image"
require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes"
+pi_web_extra_zypper_packages_env=$(dotenv_quote "$pi_web_extra_zypper_packages")
+
umask 077
temp_env=$env_file.$$
cat >"$temp_env" <
Date: Sun, 21 Jun 2026 22:51:29 +0000
Subject: [PATCH 11/20] fix: name docker runtime user
---
.changeset/docker-user-name.md | 5 +++++
docker/Dockerfile | 4 +++-
docker/Dockerfile.dev | 2 ++
docker/README.md | 6 +++---
docker/bin/install-opensuse-base | 36 ++++++++++++++++++++++++++------
docker/compose.dev.yml | 2 ++
docker/compose.yml | 2 ++
docker/install.sh | 2 +-
8 files changed, 48 insertions(+), 11 deletions(-)
create mode 100644 .changeset/docker-user-name.md
diff --git a/.changeset/docker-user-name.md b/.changeset/docker-user-name.md
new file mode 100644
index 0000000..cfddfc7
--- /dev/null
+++ b/.changeset/docker-user-name.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Create the Docker image `pi-web` user with the configured host UID/GID so container terminals show a normal username instead of `I have no name!`.
diff --git a/docker/Dockerfile b/docker/Dockerfile
index c411301..bb2b80a 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -10,6 +10,8 @@ FROM ${OPENSUSE_IMAGE} AS base
ARG NODEJS_MAJOR=22
ARG NODEJS_REPO=auto
ARG PI_WEB_EXTRA_ZYPPER_PACKAGES=""
+ARG PI_WEB_UID=1000
+ARG PI_WEB_GID=1000
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@@ -68,7 +70,7 @@ RUN bash -euxo pipefail -c '\
'
WORKDIR /workspace
-USER node
+USER pi-web
EXPOSE 8504
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index a1a9608..f5eda36 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -10,6 +10,8 @@ FROM ${OPENSUSE_IMAGE} AS dev
ARG NODEJS_MAJOR=22
ARG NODEJS_REPO=auto
ARG PI_WEB_EXTRA_ZYPPER_PACKAGES=""
+ARG PI_WEB_UID=1000
+ARG PI_WEB_GID=1000
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
diff --git a/docker/README.md b/docker/README.md
index d9bfb74..fd9cd75 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -91,7 +91,7 @@ Common environment variables written to `.env`:
| Variable | Purpose |
| --- | --- |
-| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers |
+| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers and the image's `pi-web` account |
| `DOCKER_GID` | extra group used for Docker socket access |
| `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount |
| `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port |
@@ -108,7 +108,7 @@ Host-derived IDs are refreshed on rerun unless you explicitly override them. Use
### Base image and tooling
-The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI.
+The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID`, so shells have a passwd entry instead of showing `I have no name!` when the host user is not `1000:1000`. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI.
Install extra distro packages without writing a hook by setting a whitespace-delimited package list:
@@ -266,7 +266,7 @@ Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`,
The dev setup intentionally has the same Docker socket and broad host mounts as the runtime setup. The same trust warnings apply.
-On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon.
+On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon. Because the image also builds its `pi-web` account with those IDs, rebuild the image if you change `PI_WEB_UID` or `PI_WEB_GID`.
### Sharing runtime and development state
diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base
index 7e0adfe..fdfb888 100755
--- a/docker/bin/install-opensuse-base
+++ b/docker/bin/install-opensuse-base
@@ -4,6 +4,8 @@ set -euo pipefail
nodejs_major=${NODEJS_MAJOR:-22}
nodejs_repo=${NODEJS_REPO:-auto}
extra_zypper_packages=${PI_WEB_EXTRA_ZYPPER_PACKAGES:-}
+runtime_uid=${PI_WEB_UID:-1000}
+runtime_gid=${PI_WEB_GID:-1000}
nodejs_repo_flavor() {
local rpm_arch
@@ -120,16 +122,38 @@ npx --version
python3 --version
git --version
-if ! getent group node >/dev/null 2>&1; then
- groupadd --gid 1000 node
+case "$runtime_uid" in
+ ""|*[!0-9]*)
+ echo "PI_WEB_UID must be a numeric user ID, got: $runtime_uid" >&2
+ exit 1
+ ;;
+esac
+
+case "$runtime_gid" in
+ ""|*[!0-9]*)
+ echo "PI_WEB_GID must be a numeric group ID, got: $runtime_gid" >&2
+ exit 1
+ ;;
+esac
+
+runtime_user=pi-web
+runtime_group=pi-web
+if getent group "$runtime_gid" >/dev/null 2>&1; then
+ runtime_group=$(getent group "$runtime_gid" | cut -d: -f1)
+elif getent group "$runtime_group" >/dev/null 2>&1; then
+ groupmod --gid "$runtime_gid" "$runtime_group"
+else
+ groupadd --gid "$runtime_gid" "$runtime_group"
fi
-if ! id node >/dev/null 2>&1; then
- useradd --uid 1000 --gid node --create-home --home-dir /home/node --shell /bin/bash node
+if id "$runtime_user" >/dev/null 2>&1; then
+ usermod --non-unique --uid "$runtime_uid" --gid "$runtime_group" --home "/home/$runtime_user" --shell /bin/bash "$runtime_user"
+else
+ useradd --non-unique --uid "$runtime_uid" --gid "$runtime_group" --create-home --home-dir "/home/$runtime_user" --shell /bin/bash "$runtime_user"
fi
-mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace
-chown -R node:node /data /workspace /home/node
+mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace "/home/$runtime_user"
+chown -R "$runtime_uid:$runtime_gid" /data /workspace "/home/$runtime_user"
zypper clean --all
rm -rf /var/cache/zypp/*
diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml
index cf582de..fec1abc 100644
--- a/docker/compose.dev.yml
+++ b/docker/compose.dev.yml
@@ -8,6 +8,8 @@ x-pi-web-dev-build: &pi-web-dev-build
NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22}
NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto}
PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-}
+ PI_WEB_UID: ${PI_WEB_UID:-1000}
+ PI_WEB_GID: ${PI_WEB_GID:-1000}
x-pi-web-dev-environment: &pi-web-dev-environment
HOME: /data/home
diff --git a/docker/compose.yml b/docker/compose.yml
index b35a6bd..9e37247 100644
--- a/docker/compose.yml
+++ b/docker/compose.yml
@@ -8,6 +8,8 @@ x-pi-web-build: &pi-web-build
NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22}
NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto}
PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-}
+ PI_WEB_UID: ${PI_WEB_UID:-1000}
+ PI_WEB_GID: ${PI_WEB_GID:-1000}
PI_WEB_VERSION: ${PI_WEB_VERSION:-latest}
PI_VERSION: ${PI_VERSION:-latest}
CACHE_BUST: ${CACHE_BUST:-local}
diff --git a/docker/install.sh b/docker/install.sh
index 5e26fda..cfd902c 100755
--- a/docker/install.sh
+++ b/docker/install.sh
@@ -388,7 +388,7 @@ cat >"$temp_env" <
Date: Thu, 25 Jun 2026 19:46:24 +0000
Subject: [PATCH 12/20] fix(docker): include compose and persist home
---
.changeset/docker-compose-home.md | 5 +++++
README.md | 21 ---------------------
docker/Dockerfile | 1 +
docker/Dockerfile.dev | 1 +
docker/README.md | 6 ++++--
docker/bin/install-opensuse-base | 10 ++++++----
6 files changed, 17 insertions(+), 27 deletions(-)
create mode 100644 .changeset/docker-compose-home.md
diff --git a/.changeset/docker-compose-home.md b/.changeset/docker-compose-home.md
new file mode 100644
index 0000000..31d89d7
--- /dev/null
+++ b/.changeset/docker-compose-home.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Keep the Docker setup documented as beta-only, include Docker Compose/Buildx in the container images, and make the container user's home persist under `/data/home`.
diff --git a/README.md b/README.md
index dba168a..c1b2f30 100644
--- a/README.md
+++ b/README.md
@@ -72,16 +72,6 @@ Common alternatives:
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh
```
-For trusted local/server installs, PI WEB also has a Docker local-build runtime:
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
-```
-
-The Docker setup builds an openSUSE Tumbleweed based local image from npm, runs split `sessiond` and `web` services, and binds the browser UI to `127.0.0.1:8504` by default. It intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
-
-See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, package customization, host command examples, and development Compose usage.
-
PI WEB is also published as a Pi package:
```bash
@@ -183,17 +173,6 @@ pi-web install --dev
`dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions.
-Docker development from the checkout is available too:
-
-```bash
-export PI_WEB_UID=$(id -u)
-export PI_WEB_GID=$(id -g)
-export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
-docker compose -f docker/compose.dev.yml up --build
-```
-
-Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service and uses the runtime Docker data directory by default so sessions can be shared across modes. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup).
-
For a production-style run from a checkout:
```bash
diff --git a/docker/Dockerfile b/docker/Dockerfile
index bb2b80a..41a802d 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -54,6 +54,7 @@ ENV NODE_ENV=production \
COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=package /usr/local/bin /usr/local/bin
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
COPY bin/hostexec /usr/local/bin/hostexec
RUN chmod 0755 /usr/local/bin/hostexec
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index f5eda36..a352a2d 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -43,6 +43,7 @@ RUN npm ci \
&& chmod 0777 /workspace
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
+COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
COPY docker/bin/hostexec /usr/local/bin/hostexec
RUN chmod 0755 /usr/local/bin/hostexec
diff --git a/docker/README.md b/docker/README.md
index fd9cd75..22e75d8 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,4 +1,6 @@
-# PI WEB Docker
+# PI WEB Docker (beta)
+
+This Docker setup is beta. It is useful for trusted local/server testing and development, but it may still have rough edges and is intentionally documented only here for now.
PI WEB has two Docker modes:
@@ -108,7 +110,7 @@ Host-derived IDs are refreshed on rerun unless you explicitly override them. Use
### Base image and tooling
-The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID`, so shells have a passwd entry instead of showing `I have no name!` when the host user is not `1000:1000`. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI.
+The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image's `pi-web` account is created with `PI_WEB_UID:PI_WEB_GID` and `/data/home` as its home directory, so shells have a passwd entry instead of showing `I have no name!` while user config stays in the persistent `/data` mount. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI with Compose and Buildx plugins.
Install extra distro packages without writing a hook by setting a whitespace-delimited package list:
diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base
index fdfb888..94acd17 100755
--- a/docker/bin/install-opensuse-base
+++ b/docker/bin/install-opensuse-base
@@ -138,6 +138,9 @@ esac
runtime_user=pi-web
runtime_group=pi-web
+runtime_home=/data/home
+mkdir -p "$runtime_home" /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace
+
if getent group "$runtime_gid" >/dev/null 2>&1; then
runtime_group=$(getent group "$runtime_gid" | cut -d: -f1)
elif getent group "$runtime_group" >/dev/null 2>&1; then
@@ -147,13 +150,12 @@ else
fi
if id "$runtime_user" >/dev/null 2>&1; then
- usermod --non-unique --uid "$runtime_uid" --gid "$runtime_group" --home "/home/$runtime_user" --shell /bin/bash "$runtime_user"
+ usermod --non-unique --uid "$runtime_uid" --gid "$runtime_group" --home "$runtime_home" --shell /bin/bash "$runtime_user"
else
- useradd --non-unique --uid "$runtime_uid" --gid "$runtime_group" --create-home --home-dir "/home/$runtime_user" --shell /bin/bash "$runtime_user"
+ useradd --non-unique --uid "$runtime_uid" --gid "$runtime_group" --no-create-home --home-dir "$runtime_home" --shell /bin/bash "$runtime_user"
fi
-mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace "/home/$runtime_user"
-chown -R "$runtime_uid:$runtime_gid" /data /workspace "/home/$runtime_user"
+chown -R "$runtime_uid:$runtime_gid" /data /workspace
zypper clean --all
rm -rf /var/cache/zypp/*
From 2987b6f282bae068cea9466a1ede4af0eb7e8d6b Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Sat, 27 Jun 2026 23:05:40 +0000
Subject: [PATCH 13/20] feat(docker): add fail-closed host profiles
---
.changeset/docker-host-profiles.md | 5 +
docker/README.md | 102 ++++++---
docker/bin/hostexec | 14 ++
docker/compose.dev.yml | 17 +-
docker/compose.yml | 17 +-
docker/install.sh | 100 +++++---
docker/lib/host-profile.sh | 355 +++++++++++++++++++++++++++++
docker/scripts/docker-compose-dev | 236 +++++++++++++++++++
8 files changed, 745 insertions(+), 101 deletions(-)
create mode 100644 .changeset/docker-host-profiles.md
create mode 100644 docker/lib/host-profile.sh
create mode 100755 docker/scripts/docker-compose-dev
diff --git a/.changeset/docker-host-profiles.md b/.changeset/docker-host-profiles.md
new file mode 100644
index 0000000..4137426
--- /dev/null
+++ b/.changeset/docker-host-profiles.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add fail-closed Docker host profile detection with Linux and Docker Desktop for Mac Compose overrides, and split Docker dev configuration into a user-editable local env file plus generated Compose inputs.
diff --git a/docker/README.md b/docker/README.md
index 22e75d8..6e7c5ac 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -13,12 +13,11 @@ No prebuilt image or registry is required in either mode.
The Docker setup is for trusted single-user or trusted-admin environments. It is not a sandbox and it is not suitable for untrusted multi-tenant use.
-By design, the runtime containers get deliberate host access so PI WEB agents can work on real server paths:
+By design, the runtime containers get deliberate host access so PI WEB agents can work on real host paths:
-- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host.
-- `/srv`, `/opt`, and `/home` are mounted read/write.
-- `/` is mounted read-only at `/host` for inspection.
-- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. Commands run as the container user by default, and `hostexec --root` can still run explicit administrative commands as root.
+- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the Docker host.
+- On native Linux Docker Engine, existing `/home`, `/srv`, and `/opt` paths are mounted read/write, `/` is mounted read-only at `/host` for inspection, and `hostexec` can run explicit commands in the Linux host namespaces.
+- On Docker Desktop for Mac, existing `/Users`, `/Volumes`, and `/private` paths are mounted read/write. `hostexec` is disabled because Docker Desktop containers run inside a Linux VM and cannot enter native macOS namespaces.
Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository.
@@ -32,10 +31,15 @@ The web port is bound to `127.0.0.1` by default. Do **not** expose PI WEB direct
Prerequisites:
-- Docker Engine with the Compose plugin (`docker compose`) or `docker-compose`;
+- one supported Docker host profile:
+ - native Linux Docker Engine using the local `/var/run/docker.sock`; or
+ - Docker Desktop for Mac;
+- Docker Compose through the `docker compose` plugin or `docker-compose`;
- a user that can talk to the Docker daemon;
- `curl` or `wget` for the one-liner installer.
+The installer fails closed on unknown or unsupported Docker setups, such as remote Docker contexts, `DOCKER_HOST` overrides outside the supported local Unix socket, rootless/alternate Linux sockets, Docker Desktop for Linux, Colima, or OrbStack. It prints the detected host OS, Docker context, endpoint, `DOCKER_HOST`, socket source, and Docker OS before exiting, and it does not recreate services.
+
Install or update with the same command:
```bash
@@ -96,6 +100,8 @@ Common environment variables written to `.env`:
| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers and the image's `pi-web` account |
| `DOCKER_GID` | extra group used for Docker socket access |
| `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount |
+| `PI_WEB_DOCKER_HOST_PROFILE`, `HOSTEXEC_MODE` | detected host profile and host-command capability toggle |
+| `PI_WEB_DOCKER_EXTRA_HOST_PATHS` | optional whitespace-separated existing absolute paths to bind-mount read/write at the same path |
| `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port |
| `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` |
| `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` |
@@ -106,7 +112,9 @@ Common environment variables written to `.env`:
| `PI_WEB_IMAGE` | local image tag to build and run |
| `HOSTEXEC_IMAGE` | helper image used by `hostexec` |
-Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
+Host-derived IDs and the Docker host profile are refreshed on rerun unless you explicitly override the IDs. User-facing values such as data directory, bind address, port, image names, upload limit, extra host paths, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
+
+The installer also writes a generated `compose.override.yml` in the install directory. Docker Compose loads it automatically for ordinary `docker compose ...` commands run from that directory; re-run the installer instead of editing that generated file by hand.
### Base image and tooling
@@ -204,9 +212,11 @@ If you use a reverse proxy, keep the container bound to localhost or a private a
## `hostexec` examples
-`hostexec [--root] ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands.
+`hostexec [--root] ` is the native Linux host command bridge provided by this Docker setup. It is enabled only for the `linux-native-docker` profile and intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands.
-Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container:
+On Docker Desktop for Mac, `hostexec` exits with a clear disabled message because the Docker daemon and containers run inside a Linux VM, not in native macOS namespaces. Docker CLI and Docker Compose commands still work through the mounted Docker socket.
+
+Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container on native Linux:
```bash
hostexec uname -a
@@ -223,7 +233,7 @@ cd ~/.local/share/pi-web-docker
docker compose exec web hostexec uname -a
```
-`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user.
+On native Linux, `hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user.
## Development Docker setup
@@ -232,24 +242,42 @@ Use this mode when developing PI WEB from this checkout. It bind-mounts the sour
- `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes;
- `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`.
-From the repository root:
+From the repository root, use the dev Compose wrapper so the same fail-closed host profile detection is applied as runtime mode:
```bash
-export PI_WEB_UID=$(id -u)
-export PI_WEB_GID=$(id -g)
-export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
-# Optional; this is also the default dev data path.
-export PI_WEB_DOCKER_DATA_DIR=${PI_WEB_DOCKER_DATA_DIR:-$HOME/.local/share/pi-web-docker/data}
-mkdir -p "$PI_WEB_DOCKER_DATA_DIR"
-
-docker compose -f docker/compose.dev.yml up --build
+./docker/scripts/docker-compose-dev up --build
```
-If you already ran the runtime installer, you can reuse its `.env` so dev mode gets the same UID/GID, Docker group, ports, and data directory:
+The wrapper creates `.pi-web/docker-compose-dev.local.env` on first run, writes `.pi-web/docker-compose-dev.generated.env` and `.pi-web/docker-compose-dev.host.generated.yml`, then runs Docker Compose with `docker/compose.dev.yml` plus that generated host override. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the wrapper.
+
+Values used by the wrapper are resolved in this order:
+
+1. current shell environment, for this run only;
+2. `.pi-web/docker-compose-dev.local.env`;
+3. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`;
+4. built-in defaults.
+
+If you already ran the runtime installer, dev mode therefore reuses defaults such as UID/GID, Docker group, data directory, extra host paths, image build inputs, upload limit, and bind address unless you set a more specific value in the shell or `.local.env`. If an older `.pi-web/docker-compose-dev.env` exists, the first run copies its dev bind/port values into `.local.env` so previous local exposure settings are easy to see and edit.
+
+To expose the dev API and Vite UI beyond localhost persistently, edit `.pi-web/docker-compose-dev.local.env`:
+
+```dotenv
+PI_WEB_DEV_API_BIND_ADDR=0.0.0.0
+PI_WEB_DEV_BIND_ADDR=0.0.0.0
+```
+
+For temporary overrides, prefix the wrapper command:
```bash
-docker compose --env-file "$HOME/.local/share/pi-web-docker/.env" \
- -f docker/compose.dev.yml up --build
+PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 \
+PI_WEB_DEV_BIND_ADDR=0.0.0.0 \
+ ./docker/scripts/docker-compose-dev up -d --build
+```
+
+You can run the dev stack in the background with:
+
+```bash
+./docker/scripts/docker-compose-dev up -d --build
```
Open the Vite UI at . The dev API is published on .
@@ -257,16 +285,16 @@ Open the Vite UI at . The dev API is published on `, `/srv/`, or `/opt/`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for `/home/core/`.
+For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Linux, prefer host-mounted paths such as `/home/core/`, `/srv/`, or `/opt/`. On Mac, prefer paths under `/Users//...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path.
When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree:
```bash
-docker compose -f docker/compose.dev.yml down
+./docker/scripts/docker-compose-dev down
docker volume rm pi-web-dev_node_modules
-docker compose -f docker/compose.dev.yml up --build
+./docker/scripts/docker-compose-dev up --build
```
## Local checkout validation
@@ -309,11 +337,17 @@ PI_WEB_DOCKER_HOME="$(mktemp -d)" \
sh docker/install.sh
```
-For Compose validation:
+For Compose validation after generating host overrides:
```bash
-docker compose -f docker/compose.yml config
-docker compose -f docker/compose.dev.yml config
+tmp_home=$(mktemp -d)
+PI_WEB_DOCKER_SKIP_COMPOSE=1 \
+PI_WEB_DOCKER_ASSET_DIR="$PWD/docker" \
+PI_WEB_DOCKER_HOME="$tmp_home" \
+sh docker/install.sh
+
+docker compose -f "$tmp_home/compose.yml" -f "$tmp_home/compose.override.yml" config
+./docker/scripts/docker-compose-dev config
docker build --check -f docker/Dockerfile docker
docker build --check -f docker/Dockerfile.dev .
```
diff --git a/docker/bin/hostexec b/docker/bin/hostexec
index c46a3d0..182cba0 100755
--- a/docker/bin/hostexec
+++ b/docker/bin/hostexec
@@ -38,6 +38,20 @@ if [ "$#" -eq 0 ]; then
exit 64
fi
+hostexec_mode="${HOSTEXEC_MODE:-nsenter}"
+case "$hostexec_mode" in
+ nsenter) ;;
+ disabled|none)
+ echo "hostexec: disabled for this Docker host profile" >&2
+ echo "hostexec: on Docker Desktop for Mac, containers run inside a Linux VM and cannot enter native macOS namespaces" >&2
+ exit 69
+ ;;
+ *)
+ echo "hostexec: unsupported HOSTEXEC_MODE: $hostexec_mode" >&2
+ exit 64
+ ;;
+esac
+
if ! command -v docker >/dev/null 2>&1; then
echo "hostexec: docker CLI not found in this container" >&2
exit 127
diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml
index fec1abc..4ffb009 100644
--- a/docker/compose.dev.yml
+++ b/docker/compose.dev.yml
@@ -18,6 +18,7 @@ x-pi-web-dev-environment: &pi-web-dev-environment
PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock
PI_CODING_AGENT_DIR: /data/pi-agent
HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
+ HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
NPM_CONFIG_UPDATE_NOTIFIER: "false"
NPM_CONFIG_CACHE: /data/npm-cache
@@ -35,22 +36,6 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes
source: node_modules
target: /workspace/node_modules
- *pi-web-dev-data-volume
- - type: bind
- source: /var/run/docker.sock
- target: /var/run/docker.sock
- - type: bind
- source: /srv
- target: /srv
- - type: bind
- source: /opt
- target: /opt
- - type: bind
- source: /home
- target: /home
- - type: bind
- source: /
- target: /host
- read_only: true
services:
data-init:
diff --git a/docker/compose.yml b/docker/compose.yml
index 9e37247..0ed4d45 100644
--- a/docker/compose.yml
+++ b/docker/compose.yml
@@ -21,28 +21,13 @@ x-pi-web-environment: &pi-web-environment
PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock
PI_CODING_AGENT_DIR: /data/pi-agent
HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
+ HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
x-pi-web-volumes: &pi-web-volumes
- type: bind
source: ${PI_WEB_DOCKER_DATA_DIR:-./data}
target: /data
- - type: bind
- source: /var/run/docker.sock
- target: /var/run/docker.sock
- - type: bind
- source: /srv
- target: /srv
- - type: bind
- source: /opt
- target: /opt
- - type: bind
- source: /home
- target: /home
- - type: bind
- source: /
- target: /host
- read_only: true
services:
sessiond:
diff --git a/docker/install.sh b/docker/install.sh
index cfd902c..5836c62 100755
--- a/docker/install.sh
+++ b/docker/install.sh
@@ -39,6 +39,12 @@ Options:
--skip-compose Write assets/.env but skip build and service recreate
-h, --help Show this help
+Progressive host setup:
+ The installer supports native Linux Docker Engine and Docker Desktop for Mac.
+ Unknown Docker hosts fail closed before services are recreated. Set
+ PI_WEB_DOCKER_EXTRA_HOST_PATHS to a whitespace-separated list of additional
+ existing absolute directories to bind-mount at the same path in the containers.
+
Environment variables with the same names used in .env may also be set before
running the installer, for example:
@@ -223,30 +229,6 @@ dotenv_quote() {
printf '"%s"' "$(printf '%s' "$value" | sed 's/[\\"]/\\&/g')"
}
-detect_docker_gid() {
- if [ -S /var/run/docker.sock ]; then
- if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then
- printf '%s\n' "$gid"
- return 0
- fi
- if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then
- printf '%s\n' "$gid"
- return 0
- fi
- fi
-
- if command -v getent >/dev/null 2>&1; then
- if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then
- if [ -n "$gid" ]; then
- printf '%s\n' "$gid"
- return 0
- fi
- fi
- fi
-
- printf '0\n'
-}
-
fetch_url() {
url=$1
target=$2
@@ -292,13 +274,11 @@ write_asset() {
}
compose_cmd() {
- if docker compose version >/dev/null 2>&1; then
- docker compose "$@"
- elif command -v docker-compose >/dev/null 2>&1; then
- docker-compose "$@"
- else
- die "Docker Compose is required (docker compose plugin or docker-compose)"
- fi
+ pi_web_docker_compose "$@"
+}
+
+run_runtime_compose() {
+ compose_cmd -f compose.yml -f compose.override.yml "$@"
}
if [ -n "${XDG_DATA_HOME:-}" ]; then
@@ -332,12 +312,37 @@ else
log "Fetching Docker assets from $asset_base"
fi
+profile_helper_temp=
+cleanup_profile_helper() {
+ [ -z "$profile_helper_temp" ] || rm -f "$profile_helper_temp"
+}
+trap cleanup_profile_helper EXIT
+
+if [ -n "$asset_dir" ]; then
+ profile_helper=$asset_dir/lib/host-profile.sh
+ [ -f "$profile_helper" ] || die "missing Docker asset: $profile_helper"
+else
+ profile_helper_temp=${TMPDIR:-/tmp}/pi-web-host-profile.$$
+ fetch_url "$asset_base/lib/host-profile.sh" "$profile_helper_temp"
+ profile_helper=$profile_helper_temp
+fi
+
+# shellcheck source=lib/host-profile.sh
+# shellcheck disable=SC1091
+. "$profile_helper"
+
+if ! pi_web_docker_host_detect_profile; then
+ pi_web_docker_host_print_detection_failure
+ die "refusing to install on an unsupported or unknown Docker host setup"
+fi
+
write_asset Dockerfile 0644
write_asset compose.yml 0644
write_asset .dockerignore 0644
write_asset install.sh 0755
write_asset bin/hostexec 0755
write_asset bin/install-opensuse-base 0755
+write_asset lib/host-profile.sh 0644
custom_image_hooks_dir=$install_dir/custom-image.d
mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir"
@@ -347,7 +352,9 @@ fi
pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)")
pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)")
-docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)")
+docker_gid=$(value_from_env_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)")
+pi_web_host_profile=$PI_WEB_DETECTED_DOCKER_HOST_PROFILE
+hostexec_mode=$PI_WEB_DETECTED_HOSTEXEC_MODE
raw_data_dir=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_DATA_DIR "$install_dir/data")
data_dir=$(absolute_dir "$(path_from_base "$install_dir" "$raw_data_dir")") || die "could not create data directory"
@@ -363,10 +370,13 @@ pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTR
pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local)
hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22)
pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
+pi_web_extra_host_paths=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
require_non_empty PI_WEB_UID "$pi_web_uid"
require_non_empty PI_WEB_GID "$pi_web_gid"
require_non_empty DOCKER_GID "$docker_gid"
+require_non_empty PI_WEB_DOCKER_HOST_PROFILE "$pi_web_host_profile"
+require_non_empty HOSTEXEC_MODE "$hostexec_mode"
require_non_empty PI_WEB_DOCKER_DATA_DIR "$data_dir"
require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr"
require_non_empty PI_WEB_PORT "$pi_web_port"
@@ -380,6 +390,11 @@ require_non_empty HOSTEXEC_IMAGE "$hostexec_image"
require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes"
pi_web_extra_zypper_packages_env=$(dotenv_quote "$pi_web_extra_zypper_packages")
+pi_web_extra_host_paths_env=$(dotenv_quote "$pi_web_extra_host_paths")
+compose_override_file=$install_dir/compose.override.yml
+if ! pi_web_docker_host_write_compose_override "$compose_override_file" "$pi_web_host_profile" "$pi_web_extra_host_paths"; then
+ die "could not write host-specific Compose override"
+fi
umask 077
temp_env=$env_file.$$
@@ -393,6 +408,11 @@ PI_WEB_UID=$pi_web_uid
PI_WEB_GID=$pi_web_gid
DOCKER_GID=$docker_gid
+# Detected Docker host profile and host capability toggles.
+PI_WEB_DOCKER_HOST_PROFILE=$pi_web_host_profile
+HOSTEXEC_MODE=$hostexec_mode
+PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths_env
+
# Persistent data and localhost-only default exposure.
PI_WEB_DOCKER_DATA_DIR=$data_dir
PI_WEB_BIND_ADDR=$pi_web_bind_addr
@@ -417,6 +437,16 @@ mv "$temp_env" "$env_file"
log "Wrote Docker assets to $install_dir"
log "Wrote runtime environment to $env_file"
+log "Wrote host Compose override to $compose_override_file"
+log "Selected PI WEB Docker host profile: $pi_web_host_profile"
+case "$pi_web_host_profile" in
+ linux-native-docker)
+ log "Enabled Linux host mounts and hostexec namespace bridge."
+ ;;
+ mac-docker-desktop)
+ log "Enabled Docker Desktop for Mac project mounts. hostexec is disabled because containers cannot enter native macOS namespaces."
+ ;;
+esac
log "Persistent PI WEB Docker data: $data_dir"
log "Custom image hooks: $custom_image_hooks_dir"
@@ -443,13 +473,13 @@ log ""
log "Building $pi_web_image with --pull --no-cache (CACHE_BUST=$cache_bust) ..."
(
cd "$install_dir"
- CACHE_BUST=$cache_bust compose_cmd -f compose.yml build --pull --no-cache
+ CACHE_BUST=$cache_bust run_runtime_compose build --pull --no-cache
)
log "Recreating split PI WEB Docker services ..."
(
cd "$install_dir"
- compose_cmd -f compose.yml up -d --force-recreate --remove-orphans
+ run_runtime_compose up -d --force-recreate --remove-orphans
)
log ""
@@ -458,5 +488,5 @@ log "Install directory: $install_dir"
log "To update later, re-run this installer."
(
cd "$install_dir"
- compose_cmd -f compose.yml ps
+ run_runtime_compose ps
)
diff --git a/docker/lib/host-profile.sh b/docker/lib/host-profile.sh
new file mode 100644
index 0000000..5158e36
--- /dev/null
+++ b/docker/lib/host-profile.sh
@@ -0,0 +1,355 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+
+pi_web_docker_host_yaml_quote() {
+ value=$1
+ escaped=$(printf '%s' "$value" | sed "s/'/''/g")
+ printf "'%s'" "$escaped"
+}
+
+pi_web_docker_host_socket_path_from_endpoint() {
+ endpoint=$1
+ case "$endpoint" in
+ unix://*) printf '%s\n' "${endpoint#unix://}" ;;
+ *) return 1 ;;
+ esac
+}
+
+pi_web_docker_host_mac_desktop_socket_path() {
+ [ -n "${HOME:-}" ] || return 1
+ printf '%s/.docker/run/docker.sock\n' "$HOME"
+}
+
+pi_web_docker_host_endpoint_is_linux_expected() {
+ endpoint=$1
+ [ "$endpoint" = unix:///var/run/docker.sock ]
+}
+
+pi_web_docker_host_endpoint_is_mac_expected() {
+ endpoint=$1
+ if ! socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$endpoint" 2>/dev/null); then
+ return 1
+ fi
+
+ case "$socket_path" in
+ /var/run/docker.sock)
+ return 0
+ ;;
+ esac
+
+ if mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null); then
+ [ "$socket_path" = "$mac_socket_path" ] && return 0
+ fi
+
+ return 1
+}
+
+pi_web_docker_host_socket_source_for_endpoint() {
+ endpoint=$1
+ pi_web_docker_host_socket_path_from_endpoint "$endpoint"
+}
+
+pi_web_docker_host_detect_docker_gid() {
+ case "${PI_WEB_DETECTED_DOCKER_HOST_PROFILE:-}" in
+ mac-docker-desktop)
+ printf '0\n'
+ return 0
+ ;;
+ esac
+
+ socket_path=/var/run/docker.sock
+ if [ -n "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-}" ]; then
+ if detected_socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$PI_WEB_DETECTED_DOCKER_ENDPOINT" 2>/dev/null); then
+ socket_path=$detected_socket_path
+ fi
+ fi
+
+ if [ -S "$socket_path" ]; then
+ if gid=$(stat -c '%g' "$socket_path" 2>/dev/null); then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ if gid=$(stat -f '%g' "$socket_path" 2>/dev/null); then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ fi
+
+ if [ -S /var/run/docker.sock ]; then
+ if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ fi
+
+ if command -v getent >/dev/null 2>&1; then
+ if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then
+ if [ -n "$gid" ]; then
+ printf '%s\n' "$gid"
+ return 0
+ fi
+ fi
+ fi
+
+ printf '0\n'
+}
+
+pi_web_docker_host_detect_profile() {
+ PI_WEB_DETECTED_HOST_OS=$(uname -s 2>/dev/null || printf 'unknown')
+ PI_WEB_DETECTED_DOCKER_CONTEXT=
+ PI_WEB_DETECTED_DOCKER_ENDPOINT=
+ PI_WEB_DETECTED_DOCKER_HOST_ENV=${DOCKER_HOST:-}
+ PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=
+ PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=
+ PI_WEB_DETECTED_DOCKER_OS=
+ PI_WEB_DETECTED_DOCKER_HOST_PROFILE=
+ PI_WEB_DETECTED_HOSTEXEC_MODE=disabled
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR=
+
+ if ! command -v docker >/dev/null 2>&1; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker CLI is required"
+ return 1
+ fi
+
+ PI_WEB_DETECTED_DOCKER_CONTEXT=$(docker context show 2>/dev/null || printf 'unknown')
+ if [ -n "$PI_WEB_DETECTED_DOCKER_CONTEXT" ] && [ "$PI_WEB_DETECTED_DOCKER_CONTEXT" != unknown ]; then
+ PI_WEB_DETECTED_DOCKER_ENDPOINT=$(docker context inspect "$PI_WEB_DETECTED_DOCKER_CONTEXT" --format '{{if .Endpoints.docker}}{{.Endpoints.docker.Host}}{{end}}' 2>/dev/null || printf '')
+ fi
+
+ case "$PI_WEB_DETECTED_HOST_OS" in
+ Linux)
+ if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require DOCKER_HOST to be unset or exactly unix:///var/run/docker.sock, not $PI_WEB_DETECTED_DOCKER_HOST_ENV"
+ return 1
+ fi
+
+ if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require the local /var/run/docker.sock Docker context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT"
+ return 1
+ fi
+
+ PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=${PI_WEB_DETECTED_DOCKER_HOST_ENV:-$PI_WEB_DETECTED_DOCKER_ENDPOINT}
+ PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock
+ if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require a local Docker socket at /var/run/docker.sock"
+ return 1
+ fi
+ ;;
+ Darwin)
+ if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT"
+ return 1
+ fi
+
+ if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ]; then
+ if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require DOCKER_HOST to be unset or a Docker Desktop local Unix socket, not $PI_WEB_DETECTED_DOCKER_HOST_ENV"
+ return 1
+ fi
+ PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_HOST_ENV
+ else
+ PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_ENDPOINT
+ fi
+
+ if [ -n "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" ]; then
+ if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT"; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket, not ${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}"
+ return 1
+ fi
+ PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$(pi_web_docker_host_socket_source_for_endpoint "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT") || return 1
+ elif mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null) && [ -S "$mac_socket_path" ]; then
+ PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$mac_socket_path
+ else
+ PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock
+ fi
+
+ if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop socket is not accessible at $PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE"
+ return 1
+ fi
+ ;;
+ *)
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="unsupported host OS: $PI_WEB_DETECTED_HOST_OS"
+ return 1
+ ;;
+ esac
+
+ if ! docker info >/dev/null 2>&1; then
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker daemon is not reachable by this user"
+ return 1
+ fi
+ PI_WEB_DETECTED_DOCKER_OS=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || printf '')
+
+ case "$PI_WEB_DETECTED_HOST_OS" in
+ Linux)
+ case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS" in
+ *desktop-linux*|*"Docker Desktop"*)
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop on Linux is not supported by this installer because it runs containers inside a VM instead of the native Linux host"
+ return 1
+ ;;
+ esac
+
+ PI_WEB_DETECTED_DOCKER_HOST_PROFILE=linux-native-docker
+ PI_WEB_DETECTED_HOSTEXEC_MODE=nsenter
+ ;;
+ Darwin)
+ case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS:$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" in
+ *desktop-linux*|*"Docker Desktop"*|*"/.docker/run/docker.sock"*)
+ PI_WEB_DETECTED_DOCKER_HOST_PROFILE=mac-docker-desktop
+ PI_WEB_DETECTED_HOSTEXEC_MODE=disabled
+ ;;
+ *)
+ PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs currently require Docker Desktop; detected context '$PI_WEB_DETECTED_DOCKER_CONTEXT' endpoint '${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}'"
+ return 1
+ ;;
+ esac
+ ;;
+ esac
+
+ return 0
+}
+
+pi_web_docker_host_write_volume() {
+ source_path=$1
+ target_path=$2
+ read_only=${3:-false}
+
+ {
+ printf ' - type: bind\n'
+ printf ' source: %s\n' "$(pi_web_docker_host_yaml_quote "$source_path")"
+ printf ' target: %s\n' "$(pi_web_docker_host_yaml_quote "$target_path")"
+ if [ "$read_only" = true ]; then
+ printf ' read_only: true\n'
+ fi
+ } >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
+}
+
+pi_web_docker_host_write_existing_volume() {
+ source_path=$1
+ target_path=$2
+ read_only=${3:-false}
+
+ if [ -e "$source_path" ]; then
+ pi_web_docker_host_write_volume "$source_path" "$target_path" "$read_only"
+ fi
+}
+
+pi_web_docker_host_write_extra_volumes() {
+ extra_paths=$1
+
+ for extra_path in $extra_paths; do
+ case "$extra_path" in
+ /*) ;;
+ *)
+ printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entries must be absolute paths: $extra_path" >&2
+ return 1
+ ;;
+ esac
+
+ if [ ! -e "$extra_path" ]; then
+ printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entry does not exist: $extra_path" >&2
+ return 1
+ fi
+
+ pi_web_docker_host_write_volume "$extra_path" "$extra_path" false
+ done
+}
+
+pi_web_docker_host_write_compose_override() {
+ target_file=$1
+ host_profile=$2
+ extra_paths=${3:-}
+ target_dir=$(dirname "$target_file")
+ mkdir -p "$target_dir" || return 1
+ PI_WEB_DOCKER_HOST_OVERRIDE_TEMP=$target_file.$$
+
+ case "$host_profile" in
+ linux-native-docker) hostexec_mode=nsenter ;;
+ mac-docker-desktop) hostexec_mode=disabled ;;
+ *)
+ printf '%s\n' "unsupported PI WEB Docker host profile: $host_profile" >&2
+ return 1
+ ;;
+ esac
+
+ cat >"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <&2
+ printf '%s\n' "" >&2
+ printf '%s\n' "Detected:" >&2
+ printf ' host OS: %s\n' "${PI_WEB_DETECTED_HOST_OS:-unknown}" >&2
+ printf ' docker context: %s\n' "${PI_WEB_DETECTED_DOCKER_CONTEXT:-unknown}" >&2
+ printf ' docker endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-unknown}" >&2
+ printf ' DOCKER_HOST: %s\n' "${PI_WEB_DETECTED_DOCKER_HOST_ENV:-unset}" >&2
+ printf ' effective endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" >&2
+ printf ' docker socket source: %s\n' "${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-unknown}" >&2
+ printf ' docker OS: %s\n' "${PI_WEB_DETECTED_DOCKER_OS:-unknown}" >&2
+ printf '%s\n' "" >&2
+ printf '%s\n' "Supported profiles:" >&2
+ printf '%s\n' " - native Linux Docker Engine using /var/run/docker.sock" >&2
+ printf '%s\n' " - Docker Desktop for Mac" >&2
+ if [ -n "${PI_WEB_DOCKER_HOST_PROFILE_ERROR:-}" ]; then
+ printf '%s\n' "" >&2
+ printf 'Reason: %s\n' "$PI_WEB_DOCKER_HOST_PROFILE_ERROR" >&2
+ fi
+}
+
+pi_web_docker_compose() {
+ if docker compose version >/dev/null 2>&1; then
+ docker compose "$@"
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose "$@"
+ else
+ printf '%s\n' "Docker Compose is required (docker compose plugin or docker-compose)" >&2
+ return 1
+ fi
+}
diff --git a/docker/scripts/docker-compose-dev b/docker/scripts/docker-compose-dev
new file mode 100755
index 0000000..334ebb3
--- /dev/null
+++ b/docker/scripts/docker-compose-dev
@@ -0,0 +1,236 @@
+#!/usr/bin/env sh
+set -eu
+
+log() {
+ printf '%s\n' "$*" >&2
+}
+
+die() {
+ log "pi-web Docker dev compose: $*"
+ exit 1
+}
+
+script_dir=$(unset CDPATH; cd "$(dirname "$0")" && pwd -P)
+repo_root=$(unset CDPATH; cd "$script_dir/../.." && pwd -P)
+dev_config_file=$repo_root/.pi-web/docker-compose-dev.local.env
+legacy_dev_env_file=$repo_root/.pi-web/docker-compose-dev.env
+generated_env_file=$repo_root/.pi-web/docker-compose-dev.generated.env
+
+# shellcheck source=../lib/host-profile.sh
+# shellcheck disable=SC1091
+. "$repo_root/docker/lib/host-profile.sh"
+
+strip_wrapping_quotes() {
+ value=$1
+ case "$value" in
+ \"*\")
+ case "$value" in
+ *\") value=${value#\"}; value=${value%\"} ;;
+ esac
+ ;;
+ \'*\')
+ case "$value" in
+ *\') value=${value#\'}; value=${value%\'} ;;
+ esac
+ ;;
+ esac
+ printf '%s\n' "$value"
+}
+
+env_file_value() {
+ file=$1
+ key=$2
+ [ -f "$file" ] || return 1
+ raw=$(awk -v key="$key" '
+ function trim(value) {
+ sub(/^[ \t]+/, "", value)
+ sub(/[ \t\r]+$/, "", value)
+ return value
+ }
+ /^[ \t]*(#|$)/ { next }
+ {
+ line = $0
+ sub(/^[ \t]*export[ \t]+/, "", line)
+ name = line
+ sub(/=.*/, "", name)
+ name = trim(name)
+ if (name == key) {
+ sub(/^[^=]*=/, "", line)
+ print trim(line)
+ found = 1
+ exit
+ }
+ }
+ END { if (!found) exit 1 }
+ ' "$file") || return 1
+ strip_wrapping_quotes "$raw"
+}
+
+dev_config_value() {
+ env_file_value "$dev_config_file" "$1"
+}
+
+runtime_env_value() {
+ env_file_value "$runtime_env_file" "$1"
+}
+
+write_initial_dev_config() {
+ [ ! -e "$dev_config_file" ] || return 0
+
+ temp_config=$dev_config_file.$$
+ previous_umask=$(umask)
+ umask 077
+ cat >"$temp_config" <<'EOF'
+# PI WEB Docker dev settings. Safe to edit.
+#
+# docker/scripts/docker-compose-dev creates this file once and does not
+# overwrite it. Put persistent dev Docker settings here.
+#
+# Precedence for values used by the wrapper:
+# 1. current shell environment
+# 2. this file
+# 3. runtime installer env, usually ~/.local/share/pi-web-docker/.env
+# 4. built-in defaults
+#
+# Generated effective values are written to:
+# .pi-web/docker-compose-dev.generated.env
+#
+# Bind addresses:
+# - 127.0.0.1 exposes only to this machine.
+# - 0.0.0.0 exposes on all host interfaces. Use only on trusted networks.
+#
+# Uncomment or add values to persist them. PI_WEB_DEV_API_BIND_ADDR
+# controls the web/API server; PI_WEB_DEV_BIND_ADDR controls the Vite UI.
+# PI_WEB_DEV_API_BIND_ADDR=127.0.0.1
+# PI_WEB_DEV_BIND_ADDR=127.0.0.1
+# PI_WEB_DEV_API_PORT=8504
+# PI_WEB_DEV_PORT=8505
+#
+# Shared Docker/runtime-style defaults may also be set here:
+# PI_WEB_DOCKER_DATA_DIR=/absolute/path/to/pi-web-docker/data
+# PI_WEB_DOCKER_EXTRA_HOST_PATHS="/absolute/path/one /absolute/path/two"
+EOF
+ umask "$previous_umask"
+
+ if [ -f "$legacy_dev_env_file" ]; then
+ {
+ printf '\n%s\n' "# Values copied from the previous generated dev env file."
+ printf '%s\n' "# Keep, edit, or delete these lines as needed."
+ for key in PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT; do
+ if value=$(env_file_value "$legacy_dev_env_file" "$key"); then
+ printf '%s=%s\n' "$key" "$value"
+ fi
+ done
+ } >>"$temp_config"
+ fi
+
+ mv "$temp_config" "$dev_config_file"
+ log "Created user-editable dev config: $dev_config_file"
+}
+
+value_from_env_or_config_or_runtime_or_default() {
+ key=$1
+ default_value=$2
+ eval "is_set=\${$key+x}"
+ if [ "${is_set:-}" = x ]; then
+ eval "printf '%s\n' \"\${$key}\""
+ elif existing=$(dev_config_value "$key"); then
+ printf '%s\n' "$existing"
+ elif existing=$(runtime_env_value "$key"); then
+ printf '%s\n' "$existing"
+ else
+ printf '%s\n' "$default_value"
+ fi
+}
+
+if ! pi_web_docker_host_detect_profile; then
+ pi_web_docker_host_print_detection_failure
+ die "refusing to run Docker Compose for an unsupported or unknown host setup"
+fi
+
+runtime_env_file=${PI_WEB_DOCKER_RUNTIME_ENV_FILE:-}
+if [ -z "$runtime_env_file" ] && [ -n "${HOME:-}" ]; then
+ runtime_env_file=$HOME/.local/share/pi-web-docker/.env
+fi
+
+mkdir -p "$repo_root/.pi-web" || die "could not create .pi-web directory"
+write_initial_dev_config
+
+pi_web_uid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_UID "$(id -u)")
+pi_web_gid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_GID "$(id -g)")
+docker_gid=$(value_from_env_or_config_or_runtime_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)")
+default_data_dir=${HOME:-$repo_root/.pi-web}/.local/share/pi-web-docker/data
+pi_web_data_dir=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir")
+pi_web_extra_host_paths=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
+pi_web_opensuse_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed)
+pi_web_nodejs_major=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22)
+pi_web_nodejs_repo=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_REPO auto)
+pi_web_extra_zypper_packages=$(value_from_env_or_config_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
+pi_web_dev_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev)
+hostexec_image=$(value_from_env_or_config_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22)
+pi_web_max_upload_bytes=$(value_from_env_or_config_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
+default_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1)
+pi_web_dev_api_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr")
+pi_web_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr")
+pi_web_dev_api_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_PORT 8504)
+pi_web_dev_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_PORT 8505)
+
+mkdir -p "$pi_web_data_dir" || die "could not create data directory: $pi_web_data_dir"
+
+env_file=$generated_env_file
+override_file=$repo_root/.pi-web/docker-compose-dev.host.generated.yml
+
+if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths"; then
+ die "could not write host-specific Compose override"
+fi
+
+umask 077
+temp_env=$env_file.$$
+cat >"$temp_env" <
Date: Sat, 27 Jun 2026 23:09:04 +0000
Subject: [PATCH 14/20] chore(changesets): consolidate docker release note
---
.changeset/docker-beta-runtime-dev.md | 5 +++++
.changeset/docker-compose-home.md | 5 -----
.changeset/docker-custom-image-hooks.md | 5 -----
.changeset/docker-dev-data-permissions.md | 5 -----
.changeset/docker-development-setup.md | 5 -----
.changeset/docker-host-command-bridge.md | 5 -----
.changeset/docker-host-profiles.md | 5 -----
.changeset/docker-installer-update.md | 5 -----
.changeset/docker-shared-dev-data.md | 5 -----
.changeset/docker-usage-docs.md | 5 -----
.changeset/docker-user-name.md | 5 -----
.changeset/hostexec-container-user.md | 5 -----
.changeset/opensuse-tumbleweed-docker.md | 5 -----
13 files changed, 5 insertions(+), 60 deletions(-)
create mode 100644 .changeset/docker-beta-runtime-dev.md
delete mode 100644 .changeset/docker-compose-home.md
delete mode 100644 .changeset/docker-custom-image-hooks.md
delete mode 100644 .changeset/docker-dev-data-permissions.md
delete mode 100644 .changeset/docker-development-setup.md
delete mode 100644 .changeset/docker-host-command-bridge.md
delete mode 100644 .changeset/docker-host-profiles.md
delete mode 100644 .changeset/docker-installer-update.md
delete mode 100644 .changeset/docker-shared-dev-data.md
delete mode 100644 .changeset/docker-usage-docs.md
delete mode 100644 .changeset/docker-user-name.md
delete mode 100644 .changeset/hostexec-container-user.md
delete mode 100644 .changeset/opensuse-tumbleweed-docker.md
diff --git a/.changeset/docker-beta-runtime-dev.md b/.changeset/docker-beta-runtime-dev.md
new file mode 100644
index 0000000..85b2929
--- /dev/null
+++ b/.changeset/docker-beta-runtime-dev.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add a beta Docker runtime and development setup with local image builds, split session/web services, profile-specific host access for native Linux and Docker Desktop for Mac, shared persistent data, custom image hooks, and Docker usage documentation.
diff --git a/.changeset/docker-compose-home.md b/.changeset/docker-compose-home.md
deleted file mode 100644
index 31d89d7..0000000
--- a/.changeset/docker-compose-home.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Keep the Docker setup documented as beta-only, include Docker Compose/Buildx in the container images, and make the container user's home persist under `/data/home`.
diff --git a/.changeset/docker-custom-image-hooks.md b/.changeset/docker-custom-image-hooks.md
deleted file mode 100644
index bda892a..0000000
--- a/.changeset/docker-custom-image-hooks.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Add Docker custom image hooks so local installs can add optional CLIs without bloating the default image.
diff --git a/.changeset/docker-dev-data-permissions.md b/.changeset/docker-dev-data-permissions.md
deleted file mode 100644
index 6a0b7ea..0000000
--- a/.changeset/docker-dev-data-permissions.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Initialize shared Docker development data directory ownership before starting the dev session daemon.
diff --git a/.changeset/docker-development-setup.md b/.changeset/docker-development-setup.md
deleted file mode 100644
index cab3d24..0000000
--- a/.changeset/docker-development-setup.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Add a Docker development setup that builds from the local checkout while keeping the session daemon separate from autoreloading web/API/client services.
diff --git a/.changeset/docker-host-command-bridge.md b/.changeset/docker-host-command-bridge.md
deleted file mode 100644
index b740e7d..0000000
--- a/.changeset/docker-host-command-bridge.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Add a Docker runtime host command bridge for explicitly running host administration commands from containerized PI WEB sessions.
diff --git a/.changeset/docker-host-profiles.md b/.changeset/docker-host-profiles.md
deleted file mode 100644
index 4137426..0000000
--- a/.changeset/docker-host-profiles.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Add fail-closed Docker host profile detection with Linux and Docker Desktop for Mac Compose overrides, and split Docker dev configuration into a user-editable local env file plus generated Compose inputs.
diff --git a/.changeset/docker-installer-update.md b/.changeset/docker-installer-update.md
deleted file mode 100644
index 2bb5d9d..0000000
--- a/.changeset/docker-installer-update.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Add a one-liner Docker install/update flow that refreshes local runtime assets, preserves persistent data, rebuilds without cache, and recreates the split PI WEB services.
diff --git a/.changeset/docker-shared-dev-data.md b/.changeset/docker-shared-dev-data.md
deleted file mode 100644
index 3ab67da..0000000
--- a/.changeset/docker-shared-dev-data.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Share the Docker development data mount with the runtime Docker data directory by default so Pi sessions can be reused across modes.
diff --git a/.changeset/docker-usage-docs.md b/.changeset/docker-usage-docs.md
deleted file mode 100644
index 5de2bcc..0000000
--- a/.changeset/docker-usage-docs.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Document Docker runtime and development usage, including trust warnings, update/version pinning, localhost exposure, and host command examples.
diff --git a/.changeset/docker-user-name.md b/.changeset/docker-user-name.md
deleted file mode 100644
index cfddfc7..0000000
--- a/.changeset/docker-user-name.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Create the Docker image `pi-web` user with the configured host UID/GID so container terminals show a normal username instead of `I have no name!`.
diff --git a/.changeset/hostexec-container-user.md b/.changeset/hostexec-container-user.md
deleted file mode 100644
index 6302643..0000000
--- a/.changeset/hostexec-container-user.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Run Docker host command bridge commands as the PI WEB container user by default, with `hostexec --root` for administrative commands.
diff --git a/.changeset/opensuse-tumbleweed-docker.md b/.changeset/opensuse-tumbleweed-docker.md
deleted file mode 100644
index 387bb50..0000000
--- a/.changeset/opensuse-tumbleweed-docker.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Move the Docker runtime and development images to openSUSE Tumbleweed with Node.js 22, npx, Corepack, and common development tooling, plus zypper-based package customization.
From bd28c93cfeac576d1fe43fbd69a5faff73272c3e Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Mon, 29 Jun 2026 11:27:47 +0000
Subject: [PATCH 15/20] feat: unify Docker entrypoint
---
.changeset/docker-beta-runtime-dev.md | 2 +-
.changeset/docker-updates-tab.md | 5 +
README.md | 8 +
docker/.dockerignore | 9 +-
docker/Dockerfile | 7 +-
docker/Dockerfile.dev | 7 +-
docker/README.md | 94 +--
docker/compose.dev.yml | 8 +
docker/compose.yml | 5 +
docker/install.sh | 43 +-
docker/{ => internal}/bin/hostexec | 0
.../dev/compose} | 147 +++-
docker/{lib => internal}/host-profile.sh | 12 +-
.../image}/install-opensuse-base | 0
docker/pi-web-docker | 693 ++++++++++++++++++
docs/install.html | 24 +-
docs/plugins.html | 2 +-
docs/plugins.md | 2 +-
pi-web-plugins/updates/updatesLogic.test.ts | 59 +-
pi-web-plugins/updates/updatesLogic.ts | 9 +-
pi-web-plugins/workspace-tasks/config.test.ts | 4 +-
src/client/src/api.ts | 2 +-
src/client/src/api/parsers.test.ts | 29 +-
src/client/src/api/parsers.ts | 5 +-
src/docker/piWebDockerCommandPlan.test.ts | 166 +++++
src/docker/piWebDockerCommandPlan.ts | 274 +++++++
src/docker/piWebDockerDocs.test.ts | 44 ++
src/piWebVersionReport.ts | 4 +
src/plugin-api.ts | 1 +
src/server/dockerControlAssets.test.ts | 571 +++++++++++++++
src/server/piWebStatus.test.ts | 92 ++-
src/server/piWebStatus.ts | 56 ++
src/shared/apiTypes.ts | 4 +-
src/shared/piWebStatusParsing.test.ts | 42 ++
src/shared/piWebStatusParsing.ts | 4 +-
35 files changed, 2316 insertions(+), 118 deletions(-)
create mode 100644 .changeset/docker-updates-tab.md
rename docker/{ => internal}/bin/hostexec (100%)
rename docker/{scripts/docker-compose-dev => internal/dev/compose} (52%)
rename docker/{lib => internal}/host-profile.sh (96%)
rename docker/{bin => internal/image}/install-opensuse-base (100%)
create mode 100755 docker/pi-web-docker
create mode 100644 src/docker/piWebDockerCommandPlan.test.ts
create mode 100644 src/docker/piWebDockerCommandPlan.ts
create mode 100644 src/docker/piWebDockerDocs.test.ts
create mode 100644 src/server/dockerControlAssets.test.ts
create mode 100644 src/shared/piWebStatusParsing.test.ts
diff --git a/.changeset/docker-beta-runtime-dev.md b/.changeset/docker-beta-runtime-dev.md
index 85b2929..a931c0a 100644
--- a/.changeset/docker-beta-runtime-dev.md
+++ b/.changeset/docker-beta-runtime-dev.md
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
-Add a beta Docker runtime and development setup with local image builds, split session/web services, profile-specific host access for native Linux and Docker Desktop for Mac, shared persistent data, custom image hooks, and Docker usage documentation.
+Add a beta Docker runtime and development setup with a single `pi-web-docker` command surface, a production one-line install that does not require host Node.js, local image builds, split session/web services, profile-specific host access for native Linux and Docker Desktop for Mac, shared persistent data, custom image hooks, and Docker usage documentation.
diff --git a/.changeset/docker-updates-tab.md b/.changeset/docker-updates-tab.md
new file mode 100644
index 0000000..0ee7407
--- /dev/null
+++ b/.changeset/docker-updates-tab.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Expose Docker-aware PI WEB status, update, and restart commands in the Updates panel through the canonical `pi-web-docker` command, including explicit `pi-web-docker --dev ...` commands for Docker development runtimes, and harden production and development Docker workflows around generated Compose assets, Compose project-name isolation, root-safety checks, UID/GID preservation, and detached helper execution.
diff --git a/README.md b/README.md
index c1b2f30..e1d06bb 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,14 @@ pi install npm:@jmfederico/pi-web
In Pi, use `/pi-web install`, `/pi-web status`, `/pi-web logs`, `/pi-web restart`, `/pi-web doctor`, and `/pi-web version`.
+Docker beta runtime/server install is available when you want a local image and do not want Node.js or npm on the host:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
+```
+
+See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust model, supported host profiles, commands, and development mode.
+
## Core model
PI WEB organizes work like this:
diff --git a/docker/.dockerignore b/docker/.dockerignore
index 8a7307a..31e252e 100644
--- a/docker/.dockerignore
+++ b/docker/.dockerignore
@@ -1,9 +1,12 @@
# Keep the local-build runtime context small and avoid sending persistent data.
*
!Dockerfile
-!bin/
-!bin/hostexec
-!bin/install-opensuse-base
+!pi-web-docker
+!internal/
+!internal/bin/
+!internal/bin/hostexec
+!internal/image/
+!internal/image/install-opensuse-base
!custom-image.d/
!custom-image.d/.gitkeep
!custom-image.d/*.sh
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 41a802d..518d6d2 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -19,7 +19,7 @@ ENV NPM_CONFIG_UPDATE_NOTIFIER=false \
SHELL=/bin/bash \
TERM=xterm-256color
-COPY bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
+COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \
&& install-pi-web-opensuse-base
@@ -55,8 +55,9 @@ COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=package /usr/local/bin /usr/local/bin
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
-COPY bin/hostexec /usr/local/bin/hostexec
-RUN chmod 0755 /usr/local/bin/hostexec
+COPY internal/bin/hostexec /usr/local/bin/hostexec
+COPY pi-web-docker /usr/local/bin/pi-web-docker
+RUN chmod 0755 /usr/local/bin/hostexec /usr/local/bin/pi-web-docker
COPY custom-image.d/ /tmp/pi-web-custom-image.d/
RUN bash -euxo pipefail -c '\
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index a352a2d..e21a189 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -28,7 +28,7 @@ ENV NODE_ENV=development \
SHELL=/bin/bash \
TERM=xterm-256color
-COPY docker/bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
+COPY docker/internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \
&& install-pi-web-opensuse-base
@@ -44,8 +44,9 @@ RUN npm ci \
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
-COPY docker/bin/hostexec /usr/local/bin/hostexec
-RUN chmod 0755 /usr/local/bin/hostexec
+COPY docker/internal/bin/hostexec /usr/local/bin/hostexec
+COPY docker/pi-web-docker /usr/local/bin/pi-web-docker
+RUN chmod 0755 /usr/local/bin/hostexec /usr/local/bin/pi-web-docker
COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/
RUN bash -euxo pipefail -c '\
diff --git a/docker/README.md b/docker/README.md
index 6e7c5ac..17f3a96 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -7,7 +7,7 @@ PI WEB has two Docker modes:
- **Runtime/server mode** builds a local image from npm packages and runs split `sessiond` + `web` services. This is for users and servers.
- **Development mode** builds from this checkout and runs the same split shape while letting the web/API/client services autoreload. This is for hacking on PI WEB.
-No prebuilt image or registry is required in either mode.
+No prebuilt image or registry is required in either mode. The single human-facing Docker entrypoint is `pi-web-docker`: runtime mode is the default, and development mode is explicit with `--dev`.
## Trust model: read this first
@@ -40,13 +40,15 @@ Prerequisites:
The installer fails closed on unknown or unsupported Docker setups, such as remote Docker contexts, `DOCKER_HOST` overrides outside the supported local Unix socket, rootless/alternate Linux sockets, Docker Desktop for Linux, Colima, or OrbStack. It prints the detected host OS, Docker context, endpoint, `DOCKER_HOST`, socket source, and Docker OS before exiting, and it does not recreate services.
-Install or update with the same command:
+The Docker bootstrap does not require Node.js or npm on the host. It only needs a supported Docker/Compose setup plus `curl` or `wget`; Node and PI WEB are installed inside the local Docker image.
+
+Install with the bootstrap one-liner:
```bash
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
```
-The one-liner is idempotent. Each run refreshes Docker assets from the requested Git ref, writes host-specific `.env` values, rebuilds the local image from npm with `--pull --no-cache`, and recreates the split services without deleting persistent data.
+The one-liner is idempotent. Each run refreshes Docker assets from the requested Git ref, writes host-specific `.env` values, rebuilds the local image from npm with `--pull --no-cache`, and recreates the split services without deleting persistent data. After installation, use the canonical runtime command in the install directory, for example `~/.local/share/pi-web-docker/pi-web-docker update`.
Defaults:
@@ -57,24 +59,26 @@ Defaults:
Updating recreates the Docker `sessiond` container. Active Pi agent runtimes in this Docker install may stop, so update while sessions are idle. Persisted PI WEB state, Pi config, and session history under the data directory are kept.
-Useful runtime commands:
+Inside the Docker runtime, the Updates panel uses `pi-web-docker` for status, update, and restart commands. Update and restart commands first start a detached helper container with the same Docker/host mounts and generated Compose environment, including the project name, ports/data paths, helper image, and generated UID/GID/Docker group. The helper then runs Docker Compose, so work continues even when `web`, `sessiond`, or the PI WEB terminal that launched the command exits.
-```bash
-cd ~/.local/share/pi-web-docker
+### Command matrix
-docker compose ps
-docker compose logs -f web
-docker compose logs -f sessiond
-docker compose restart web
-docker compose restart sessiond
-```
+From a production/runtime install directory, run `./pi-web-docker `. From a checkout, run `./docker/pi-web-docker --dev ` for development mode. Inside PI WEB Docker containers and in the Updates panel, the command name is `pi-web-docker`; development commands include the explicit `--dev` flag, for example `pi-web-docker --dev status`.
-To stop the runtime without deleting data:
-
-```bash
-cd ~/.local/share/pi-web-docker
-docker compose down
-```
+| Command | Runtime/default | Development | Notes |
+| --- | --- | --- | --- |
+| `install` | one-liner above or `./pi-web-docker install [installer args]` | Not available | Production bootstrap/install only; accepts the installer options below. |
+| `start` | `./pi-web-docker start` | `./docker/pi-web-docker --dev start` | Starts the split `web` and `sessiond` stack. |
+| `stop` | `./pi-web-docker stop` | `./docker/pi-web-docker --dev stop` | Stops containers without deleting persistent data. |
+| `restart` | `./pi-web-docker restart` | `./docker/pi-web-docker --dev restart` | Restarts `web` and `sessiond`. |
+| `restart-web` | `./pi-web-docker restart-web` | `./docker/pi-web-docker --dev restart-web` | Restarts only the web/API service. |
+| `restart-sessiond` | `./pi-web-docker restart-sessiond` | `./docker/pi-web-docker --dev restart-sessiond` | Restarts the session daemon; active agent runtimes may stop in that Docker stack. |
+| `update` | `./pi-web-docker update` | `./docker/pi-web-docker --dev update` | Rebuilds/recreates the stack. Runtime host updates rerun the installer to refresh Docker assets first. |
+| `status` | `./pi-web-docker status` | `./docker/pi-web-docker --dev status` | Shows Docker Compose service status. |
+| `logs` | `./pi-web-docker logs [web\|sessiond]` | `./docker/pi-web-docker --dev logs [web\|sessiond\|data-init]` | Follows logs; omitting a target follows all services. |
+| `shell` | `./pi-web-docker shell [web\|sessiond]` | `./docker/pi-web-docker --dev shell [web\|sessiond]` | Opens Bash in `web` by default. |
+| `doctor` | `./pi-web-docker doctor` | `./docker/pi-web-docker --dev doctor` | Prints static Docker command diagnostics and generated asset paths. |
+| `cli` | `./pi-web-docker cli ` | `./docker/pi-web-docker --dev cli ` | Proxies the existing `pi-web` CLI in the `web` container. |
Do not run `docker compose down -v` unless you intentionally want to remove Compose-managed volumes. The default persistent PI WEB data is a bind mount, but avoiding `-v` keeps the update/stop flow conservative.
@@ -100,6 +104,8 @@ Common environment variables written to `.env`:
| `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers and the image's `pi-web` account |
| `DOCKER_GID` | extra group used for Docker socket access |
| `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount |
+| `PI_WEB_DOCKER_INSTALL_DIR` | absolute runtime install directory mounted back into the containers for Docker helper commands |
+| `PI_WEB_DOCKER_REF` | Git ref used when `pi-web-docker update` refreshes Docker asset templates |
| `PI_WEB_DOCKER_HOST_PROFILE`, `HOSTEXEC_MODE` | detected host profile and host-command capability toggle |
| `PI_WEB_DOCKER_EXTRA_HOST_PATHS` | optional whitespace-separated existing absolute paths to bind-mount read/write at the same path |
| `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port |
@@ -110,11 +116,12 @@ Common environment variables written to `.env`:
| `PI_WEB_NODEJS_REPO` | Node.js zypper repository URL, `auto`, or `disabled` |
| `PI_WEB_EXTRA_ZYPPER_PACKAGES` | extra openSUSE packages installed during the image build |
| `PI_WEB_IMAGE` | local image tag to build and run |
+| `COMPOSE_PROJECT_NAME` | Docker Compose project name used by the runtime and its detached update/restart helpers; defaults to `pi-web` |
| `HOSTEXEC_IMAGE` | helper image used by `hostexec` |
Host-derived IDs and the Docker host profile are refreshed on rerun unless you explicitly override the IDs. User-facing values such as data directory, bind address, port, image names, upload limit, extra host paths, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override.
-The installer also writes a generated `compose.override.yml` in the install directory. Docker Compose loads it automatically for ordinary `docker compose ...` commands run from that directory; re-run the installer instead of editing that generated file by hand.
+The installer also writes a generated `compose.override.yml` in the install directory. `pi-web-docker` loads the generated `.env` and Compose override explicitly for runtime commands and passes the generated `COMPOSE_PROJECT_NAME` to Docker Compose, so an unrelated ambient Compose project name cannot redirect lifecycle commands. Re-run `pi-web-docker install` or `pi-web-docker update` instead of editing generated files by hand.
### Base image and tooling
@@ -242,22 +249,25 @@ Use this mode when developing PI WEB from this checkout. It bind-mounts the sour
- `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes;
- `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`.
-From the repository root, use the dev Compose wrapper so the same fail-closed host profile detection is applied as runtime mode:
+From the repository root, use the canonical Docker command so the same fail-closed host profile detection is applied as runtime mode:
```bash
-./docker/scripts/docker-compose-dev up --build
+./docker/pi-web-docker --dev start
```
-The wrapper creates `.pi-web/docker-compose-dev.local.env` on first run, writes `.pi-web/docker-compose-dev.generated.env` and `.pi-web/docker-compose-dev.host.generated.yml`, then runs Docker Compose with `docker/compose.dev.yml` plus that generated host override. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the wrapper.
+The command creates `.pi-web/docker-compose-dev.local.env` on first run, writes `.pi-web/docker-compose-dev.generated.env` and `.pi-web/docker-compose-dev.host.generated.yml`, then runs Docker Compose with `docker/compose.dev.yml` plus that generated host override. The generated environment includes the host repository root as `PI_WEB_DOCKER_DEV_REPO_ROOT`, and the generated override mounts that path back into the containers so Docker helper commands can run Compose from the same absolute path. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the command.
-Values used by the wrapper are resolved in this order:
+Values used by the command are resolved in this order:
-1. current shell environment, for this run only;
-2. `.pi-web/docker-compose-dev.local.env`;
-3. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`;
-4. built-in defaults.
+1. `.pi-web/docker-compose-dev.local.env`;
+2. previous generated values in `.pi-web/docker-compose-dev.generated.env`, when present;
+3. current shell environment, on first generation only;
+4. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`;
+5. built-in defaults.
-If you already ran the runtime installer, dev mode therefore reuses defaults such as UID/GID, Docker group, data directory, extra host paths, image build inputs, upload limit, and bind address unless you set a more specific value in the shell or `.local.env`. If an older `.pi-web/docker-compose-dev.env` exists, the first run copies its dev bind/port values into `.local.env` so previous local exposure settings are easy to see and edit.
+`COMPOSE_PROJECT_NAME`, `PI_WEB_UID`, and `PI_WEB_GID` are the exceptions to runtime-env reuse. Development mode defaults the Compose project to `pi-web-dev` and defaults the container user/group to the current host user, unless you set values in the shell or `.pi-web/docker-compose-dev.local.env`. This keeps development and runtime stacks from accidentally sharing one Docker Compose project and prevents bind-mounted checkout files from being written as root or as a different runtime service user.
+
+If you already ran the runtime installer, dev mode therefore reuses shared defaults such as Docker group, data directory, extra host paths, image build inputs, upload limit, and bind address unless you set a more specific value in the shell or `.local.env`. If an older `.pi-web/docker-compose-dev.env` exists, the first run copies its dev bind/port values into `.local.env` so previous local exposure settings are easy to see and edit.
To expose the dev API and Vite UI beyond localhost persistently, edit `.pi-web/docker-compose-dev.local.env`:
@@ -266,18 +276,18 @@ PI_WEB_DEV_API_BIND_ADDR=0.0.0.0
PI_WEB_DEV_BIND_ADDR=0.0.0.0
```
-For temporary overrides, prefix the wrapper command:
+For temporary overrides, prefix the command:
```bash
PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 \
PI_WEB_DEV_BIND_ADDR=0.0.0.0 \
- ./docker/scripts/docker-compose-dev up -d --build
+ ./docker/pi-web-docker --dev start
```
You can run the dev stack in the background with:
```bash
-./docker/scripts/docker-compose-dev up -d --build
+./docker/pi-web-docker --dev start
```
Open the Vite UI at . The dev API is published on .
@@ -285,16 +295,18 @@ Open the Vite UI at . The dev API is published on /dev/null); then
+elif [ "$use_local_asset_dir" = 1 ] && local_asset_dir=$(find_local_asset_dir 2>/dev/null) && [ "$local_asset_dir" != "$install_dir" ]; then
+ asset_dir=$local_asset_dir
asset_base=
log "Using Docker assets from $asset_dir"
else
- asset_ref=${PI_WEB_DOCKER_REF:-main}
- asset_base=${PI_WEB_DOCKER_ASSET_BASE:-https://raw.githubusercontent.com/jmfederico/pi-web/$asset_ref/docker}
asset_dir=
log "Fetching Docker assets from $asset_base"
fi
@@ -319,15 +326,15 @@ cleanup_profile_helper() {
trap cleanup_profile_helper EXIT
if [ -n "$asset_dir" ]; then
- profile_helper=$asset_dir/lib/host-profile.sh
+ profile_helper=$asset_dir/internal/host-profile.sh
[ -f "$profile_helper" ] || die "missing Docker asset: $profile_helper"
else
profile_helper_temp=${TMPDIR:-/tmp}/pi-web-host-profile.$$
- fetch_url "$asset_base/lib/host-profile.sh" "$profile_helper_temp"
+ fetch_url "$asset_base/internal/host-profile.sh" "$profile_helper_temp"
profile_helper=$profile_helper_temp
fi
-# shellcheck source=lib/host-profile.sh
+# shellcheck source=internal/host-profile.sh
# shellcheck disable=SC1091
. "$profile_helper"
@@ -340,9 +347,10 @@ write_asset Dockerfile 0644
write_asset compose.yml 0644
write_asset .dockerignore 0644
write_asset install.sh 0755
-write_asset bin/hostexec 0755
-write_asset bin/install-opensuse-base 0755
-write_asset lib/host-profile.sh 0644
+write_asset pi-web-docker 0755
+write_asset internal/bin/hostexec 0755
+write_asset internal/image/install-opensuse-base 0755
+write_asset internal/host-profile.sh 0644
custom_image_hooks_dir=$install_dir/custom-image.d
mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir"
@@ -368,6 +376,7 @@ pi_web_nodejs_major=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_MAJOR
pi_web_nodejs_repo=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_REPO auto)
pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local)
+compose_project_name=$(value_from_env_or_existing_or_default COMPOSE_PROJECT_NAME pi-web)
hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22)
pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
pi_web_extra_host_paths=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
@@ -378,6 +387,8 @@ require_non_empty DOCKER_GID "$docker_gid"
require_non_empty PI_WEB_DOCKER_HOST_PROFILE "$pi_web_host_profile"
require_non_empty HOSTEXEC_MODE "$hostexec_mode"
require_non_empty PI_WEB_DOCKER_DATA_DIR "$data_dir"
+require_non_empty PI_WEB_DOCKER_INSTALL_DIR "$install_dir"
+require_non_empty PI_WEB_DOCKER_REF "$asset_ref"
require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr"
require_non_empty PI_WEB_PORT "$pi_web_port"
require_non_empty PI_WEB_VERSION "$pi_web_version"
@@ -386,13 +397,14 @@ require_non_empty PI_WEB_OPENSUSE_IMAGE "$pi_web_opensuse_image"
require_non_empty PI_WEB_NODEJS_MAJOR "$pi_web_nodejs_major"
require_non_empty PI_WEB_NODEJS_REPO "$pi_web_nodejs_repo"
require_non_empty PI_WEB_IMAGE "$pi_web_image"
+require_non_empty COMPOSE_PROJECT_NAME "$compose_project_name"
require_non_empty HOSTEXEC_IMAGE "$hostexec_image"
require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes"
pi_web_extra_zypper_packages_env=$(dotenv_quote "$pi_web_extra_zypper_packages")
pi_web_extra_host_paths_env=$(dotenv_quote "$pi_web_extra_host_paths")
compose_override_file=$install_dir/compose.override.yml
-if ! pi_web_docker_host_write_compose_override "$compose_override_file" "$pi_web_host_profile" "$pi_web_extra_host_paths"; then
+if ! pi_web_docker_host_write_compose_override "$compose_override_file" "$pi_web_host_profile" "$pi_web_extra_host_paths" "$install_dir"; then
die "could not write host-specific Compose override"
fi
@@ -413,8 +425,10 @@ PI_WEB_DOCKER_HOST_PROFILE=$pi_web_host_profile
HOSTEXEC_MODE=$hostexec_mode
PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths_env
-# Persistent data and localhost-only default exposure.
+# Persistent data, Docker control root, and localhost-only default exposure.
PI_WEB_DOCKER_DATA_DIR=$data_dir
+PI_WEB_DOCKER_INSTALL_DIR=$install_dir
+PI_WEB_DOCKER_REF=$asset_ref
PI_WEB_BIND_ADDR=$pi_web_bind_addr
PI_WEB_PORT=$pi_web_port
@@ -428,8 +442,9 @@ PI_WEB_NODEJS_MAJOR=$pi_web_nodejs_major
PI_WEB_NODEJS_REPO=$pi_web_nodejs_repo
PI_WEB_EXTRA_ZYPPER_PACKAGES=$pi_web_extra_zypper_packages_env
-# Runtime image names and limits.
+# Runtime image names, Compose project, and limits.
PI_WEB_IMAGE=$pi_web_image
+COMPOSE_PROJECT_NAME=$compose_project_name
HOSTEXEC_IMAGE=$hostexec_image
PI_WEB_MAX_UPLOAD_BYTES=$pi_web_max_upload_bytes
EOF
@@ -485,7 +500,7 @@ log "Recreating split PI WEB Docker services ..."
log ""
log "PI WEB Docker runtime is ready: http://$pi_web_bind_addr:$pi_web_port"
log "Install directory: $install_dir"
-log "To update later, re-run this installer."
+log "To update later, run: $install_dir/pi-web-docker update"
(
cd "$install_dir"
run_runtime_compose ps
diff --git a/docker/bin/hostexec b/docker/internal/bin/hostexec
similarity index 100%
rename from docker/bin/hostexec
rename to docker/internal/bin/hostexec
diff --git a/docker/scripts/docker-compose-dev b/docker/internal/dev/compose
similarity index 52%
rename from docker/scripts/docker-compose-dev
rename to docker/internal/dev/compose
index 334ebb3..a981032 100755
--- a/docker/scripts/docker-compose-dev
+++ b/docker/internal/dev/compose
@@ -11,14 +11,14 @@ die() {
}
script_dir=$(unset CDPATH; cd "$(dirname "$0")" && pwd -P)
-repo_root=$(unset CDPATH; cd "$script_dir/../.." && pwd -P)
+repo_root=$(unset CDPATH; cd "$script_dir/../../.." && pwd -P)
dev_config_file=$repo_root/.pi-web/docker-compose-dev.local.env
legacy_dev_env_file=$repo_root/.pi-web/docker-compose-dev.env
generated_env_file=$repo_root/.pi-web/docker-compose-dev.generated.env
-# shellcheck source=../lib/host-profile.sh
+# shellcheck source=../host-profile.sh
# shellcheck disable=SC1091
-. "$repo_root/docker/lib/host-profile.sh"
+. "$repo_root/docker/internal/host-profile.sh"
strip_wrapping_quotes() {
value=$1
@@ -74,6 +74,10 @@ runtime_env_value() {
env_file_value "$runtime_env_file" "$1"
}
+generated_env_value() {
+ env_file_value "$generated_env_file" "$1"
+}
+
write_initial_dev_config() {
[ ! -e "$dev_config_file" ] || return 0
@@ -83,14 +87,15 @@ write_initial_dev_config() {
cat >"$temp_config" <<'EOF'
# PI WEB Docker dev settings. Safe to edit.
#
-# docker/scripts/docker-compose-dev creates this file once and does not
+# docker/pi-web-docker --dev creates this file once and does not
# overwrite it. Put persistent dev Docker settings here.
#
-# Precedence for values used by the wrapper:
-# 1. current shell environment
-# 2. this file
-# 3. runtime installer env, usually ~/.local/share/pi-web-docker/.env
-# 4. built-in defaults
+# Precedence for values used by docker/pi-web-docker --dev:
+# 1. this file
+# 2. previous generated values, when present
+# 3. current shell environment, on first generation only
+# 4. runtime installer env, usually ~/.local/share/pi-web-docker/.env
+# 5. built-in defaults
#
# Generated effective values are written to:
# .pi-web/docker-compose-dev.generated.env
@@ -109,6 +114,10 @@ write_initial_dev_config() {
# Shared Docker/runtime-style defaults may also be set here:
# PI_WEB_DOCKER_DATA_DIR=/absolute/path/to/pi-web-docker/data
# PI_WEB_DOCKER_EXTRA_HOST_PATHS="/absolute/path/one /absolute/path/two"
+#
+# PI_WEB_UID and PI_WEB_GID default to the current host user so
+# bind-mounted checkout files are not written as root or another user.
+# Set them here only if you intentionally want a different container user.
EOF
umask "$previous_umask"
@@ -128,21 +137,73 @@ EOF
log "Created user-editable dev config: $dev_config_file"
}
-value_from_env_or_config_or_runtime_or_default() {
+value_from_config_or_generated_or_env_or_runtime_or_default() {
key=$1
default_value=$2
- eval "is_set=\${$key+x}"
- if [ "${is_set:-}" = x ]; then
- eval "printf '%s\n' \"\${$key}\""
- elif existing=$(dev_config_value "$key"); then
+ if existing=$(dev_config_value "$key"); then
printf '%s\n' "$existing"
- elif existing=$(runtime_env_value "$key"); then
+ elif existing=$(generated_env_value "$key"); then
printf '%s\n' "$existing"
else
- printf '%s\n' "$default_value"
+ eval "is_set=\${$key+x}"
+ if [ "${is_set:-}" = x ]; then
+ eval "printf '%s\n' \"\${$key}\""
+ elif existing=$(runtime_env_value "$key"); then
+ printf '%s\n' "$existing"
+ else
+ printf '%s\n' "$default_value"
+ fi
fi
}
+value_from_config_or_generated_or_env_or_default() {
+ key=$1
+ default_value=$2
+ if existing=$(dev_config_value "$key"); then
+ printf '%s\n' "$existing"
+ elif existing=$(generated_env_value "$key"); then
+ printf '%s\n' "$existing"
+ else
+ eval "is_set=\${$key+x}"
+ if [ "${is_set:-}" = x ]; then
+ eval "printf '%s\n' \"\${$key}\""
+ else
+ printf '%s\n' "$default_value"
+ fi
+ fi
+}
+
+is_truthy() {
+ case "${1:-}" in
+ ""|0|false|FALSE|False) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+is_unsigned_int() {
+ case "${1:-}" in
+ ""|*[!0-9]*) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+require_unsigned_int() {
+ name=$1
+ value=$2
+ is_unsigned_int "$value" || die "$name must be a numeric Unix id, got: $value"
+}
+
+enforce_dev_root_safety() {
+ uid=$(id -u 2>/dev/null || printf '0')
+ [ "$uid" != 0 ] || is_truthy "${PI_WEB_DOCKER_ALLOW_ROOT:-0}" || die "refusing to run Docker development mode as root; retry with --allow-root if this is intentional"
+}
+
+enforce_non_root_dev_uid() {
+ [ "${1:-0}" -ne 0 ] || is_truthy "${PI_WEB_DOCKER_ALLOW_ROOT:-0}" || die "refusing to generate Docker development env with PI_WEB_UID=0; retry with --allow-root if this is intentional"
+}
+
+enforce_dev_root_safety
+
if ! pi_web_docker_host_detect_profile; then
pi_web_docker_host_print_detection_failure
die "refusing to run Docker Compose for an unsupported or unknown host setup"
@@ -156,42 +217,56 @@ fi
mkdir -p "$repo_root/.pi-web" || die "could not create .pi-web directory"
write_initial_dev_config
-pi_web_uid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_UID "$(id -u)")
-pi_web_gid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_GID "$(id -g)")
-docker_gid=$(value_from_env_or_config_or_runtime_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)")
+host_uid=$(id -u 2>/dev/null || printf '0')
+host_gid=$(id -g 2>/dev/null || printf '0')
+pi_web_uid=$(value_from_config_or_generated_or_env_or_default PI_WEB_UID "$host_uid")
+pi_web_gid=$(value_from_config_or_generated_or_env_or_default PI_WEB_GID "$host_gid")
+docker_gid=$(value_from_config_or_generated_or_env_or_runtime_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)")
default_data_dir=${HOME:-$repo_root/.pi-web}/.local/share/pi-web-docker/data
-pi_web_data_dir=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir")
-pi_web_extra_host_paths=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
-pi_web_opensuse_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed)
-pi_web_nodejs_major=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22)
-pi_web_nodejs_repo=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_REPO auto)
-pi_web_extra_zypper_packages=$(value_from_env_or_config_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
-pi_web_dev_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev)
-hostexec_image=$(value_from_env_or_config_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22)
-pi_web_max_upload_bytes=$(value_from_env_or_config_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
-default_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1)
-pi_web_dev_api_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr")
-pi_web_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr")
-pi_web_dev_api_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_PORT 8504)
-pi_web_dev_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_PORT 8505)
+pi_web_data_dir=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir")
+pi_web_extra_host_paths=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
+pi_web_opensuse_image=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed)
+pi_web_nodejs_major=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22)
+pi_web_nodejs_repo=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_NODEJS_REPO auto)
+pi_web_extra_zypper_packages=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
+pi_web_dev_image=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev)
+compose_project_name=$(value_from_config_or_generated_or_env_or_default COMPOSE_PROJECT_NAME pi-web-dev)
+hostexec_image=$(value_from_config_or_generated_or_env_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22)
+pi_web_max_upload_bytes=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
+default_dev_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1)
+pi_web_dev_api_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr")
+pi_web_dev_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr")
+pi_web_dev_api_port=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_API_PORT 8504)
+pi_web_dev_port=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_PORT 8505)
+
+require_unsigned_int PI_WEB_UID "$pi_web_uid"
+require_unsigned_int PI_WEB_GID "$pi_web_gid"
+require_unsigned_int DOCKER_GID "$docker_gid"
+enforce_non_root_dev_uid "$pi_web_uid"
+case "$pi_web_data_dir" in
+ /*) ;;
+ *) die "PI_WEB_DOCKER_DATA_DIR must be an absolute path, got: $pi_web_data_dir" ;;
+esac
+[ -n "$compose_project_name" ] || die "COMPOSE_PROJECT_NAME must not be empty"
mkdir -p "$pi_web_data_dir" || die "could not create data directory: $pi_web_data_dir"
env_file=$generated_env_file
override_file=$repo_root/.pi-web/docker-compose-dev.host.generated.yml
-if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths"; then
+if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths" "$repo_root"; then
die "could not write host-specific Compose override"
fi
umask 077
temp_env=$env_file.$$
cat >"$temp_env" <"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <&2
+ rm -f "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
+ return 1
+ fi
+ pi_web_docker_host_write_volume "$control_path" "$control_path" false
+ fi
+
cat >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <&2
+}
+
+die() {
+ log "pi-web-docker: $*"
+ exit 1
+}
+
+usage() {
+ cat <<'EOF'
+Usage: pi-web-docker [--dev] [--allow-root] [args...]
+
+Runtime/production mode is the default. Development mode must be selected
+explicitly with --dev.
+
+Commands:
+ install Run the production one-line/bootstrap installer
+ start Start the PI WEB Docker stack
+ stop Stop the PI WEB Docker stack without deleting data
+ restart Restart web and sessiond
+ restart-web Restart only the web service
+ restart-sessiond Restart only the session daemon
+ update Rebuild/update and recreate the Docker stack
+ status Show Docker Compose service status
+ logs [web|sessiond|data-init]
+ Follow Docker Compose logs
+ shell [web|sessiond] Open a shell in a service container
+ doctor Print static Docker command diagnostics
+ cli Run the pi-web CLI in the web container
+
+Update and restart commands launched inside a PI WEB Docker container start an
+independent helper container first so work can continue after web/sessiond exits.
+EOF
+}
+
+is_truthy() {
+ case "${1:-}" in
+ ""|0|false|FALSE|False) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+is_unsigned_int() {
+ case "${1:-}" in
+ ""|*[!0-9]*) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || die "$1 is required"
+}
+
+assert_no_args() {
+ checked_command=$1
+ shift
+ [ "$#" -eq 0 ] || die "$checked_command does not accept positional arguments"
+}
+
+assert_at_most_one_arg() {
+ checked_command=$1
+ shift
+ [ "$#" -le 1 ] || die "$checked_command accepts at most one target"
+}
+
+entrypoint_dir() {
+ script_path=${0:-}
+ case "$script_path" in
+ */*) script_dir=$(dirname "$script_path") ;;
+ *) script_dir=. ;;
+ esac
+ unset CDPATH
+ cd "$script_dir" 2>/dev/null && pwd -P
+}
+
+ENTRYPOINT_DIR=$(entrypoint_dir) || die "could not resolve entrypoint directory"
+PI_WEB_DOCKER_SELECTED_MODE=runtime
+PI_WEB_DOCKER_ALLOW_ROOT=0
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --dev)
+ PI_WEB_DOCKER_SELECTED_MODE=dev
+ shift
+ ;;
+ --allow-root)
+ PI_WEB_DOCKER_ALLOW_ROOT=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ die "unknown global option: $1"
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+command_name=${1:-}
+if [ "$#" -gt 0 ]; then
+ shift
+fi
+
+if [ -z "$command_name" ]; then
+ usage >&2
+ exit 2
+fi
+
+docker_mode() {
+ case "$PI_WEB_DOCKER_SELECTED_MODE" in
+ runtime|dev) printf '%s\n' "$PI_WEB_DOCKER_SELECTED_MODE" ;;
+ *) die "unsupported Docker mode: $PI_WEB_DOCKER_SELECTED_MODE" ;;
+ esac
+}
+
+mode_flag() {
+ case "$(docker_mode)" in
+ runtime) return 0 ;;
+ dev) printf '%s\n' --dev ;;
+ esac
+}
+
+absolute_existing_dir() {
+ dir=$1
+ (cd "$dir" && pwd -P)
+}
+
+strip_wrapping_quotes() {
+ value=$1
+ case "$value" in
+ \"*\")
+ case "$value" in
+ *\") value=${value#\"}; value=${value%\"} ;;
+ esac
+ ;;
+ \'*\')
+ case "$value" in
+ *\') value=${value#\'}; value=${value%\'} ;;
+ esac
+ ;;
+ esac
+ printf '%s\n' "$value"
+}
+
+env_file_value() {
+ file=$1
+ key=$2
+ [ -f "$file" ] || return 1
+ raw=$(awk -v key="$key" '
+ function trim(value) {
+ sub(/^[ \t]+/, "", value)
+ sub(/[ \t\r]+$/, "", value)
+ return value
+ }
+ /^[ \t]*(#|$)/ { next }
+ {
+ line = $0
+ sub(/^[ \t]*export[ \t]+/, "", line)
+ name = line
+ sub(/=.*/, "", name)
+ name = trim(name)
+ if (name == key) {
+ sub(/^[^=]*=/, "", line)
+ print trim(line)
+ found = 1
+ exit
+ }
+ }
+ END { if (!found) exit 1 }
+ ' "$file") || return 1
+ strip_wrapping_quotes "$raw"
+}
+
+runtime_root() {
+ root=${PI_WEB_DOCKER_INSTALL_DIR:-}
+ if [ -z "$root" ]; then
+ root=$ENTRYPOINT_DIR
+ fi
+ case "$root" in
+ /*) ;;
+ *) die "PI WEB Docker runtime root must be an absolute path: $root" ;;
+ esac
+ [ -d "$root" ] || die "PI WEB Docker runtime root does not exist: $root"
+ printf '%s\n' "$root"
+}
+
+dev_root() {
+ root=${PI_WEB_DOCKER_DEV_REPO_ROOT:-}
+ if [ -z "$root" ]; then
+ if [ -f "$ENTRYPOINT_DIR/compose.dev.yml" ] && [ -d "$ENTRYPOINT_DIR/.." ]; then
+ root=$(absolute_existing_dir "$ENTRYPOINT_DIR/..") || die "could not resolve Docker development repo root"
+ elif [ -f "$ENTRYPOINT_DIR/docker/compose.dev.yml" ]; then
+ root=$ENTRYPOINT_DIR
+ fi
+ fi
+ [ -n "$root" ] || die "PI_WEB_DOCKER_DEV_REPO_ROOT must be set or pi-web-docker must run from this checkout's docker/ directory"
+ case "$root" in
+ /*) ;;
+ *) die "PI WEB Docker development repo root must be an absolute path: $root" ;;
+ esac
+ [ -d "$root" ] || die "PI WEB Docker development repo root does not exist: $root"
+ printf '%s\n' "$root"
+}
+
+control_root() {
+ case "$(docker_mode)" in
+ runtime) runtime_root ;;
+ dev) dev_root ;;
+ esac
+}
+
+enforce_dev_root_safety() {
+ [ "$(docker_mode)" = dev ] || return 0
+ [ "$PI_WEB_DOCKER_ALLOW_ROOT" != 1 ] || return 0
+ uid=$(id -u 2>/dev/null || printf '0')
+ [ "$uid" != 0 ] || die "refusing to run Docker development mode as root; retry with --allow-root if this is intentional"
+}
+
+enforce_container_mode_match() {
+ is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || return 0
+ runtime_mode=${PI_WEB_DOCKER_MODE:-}
+ [ -n "$runtime_mode" ] || return 0
+ case "$runtime_mode" in
+ runtime|dev) ;;
+ *) die "unsupported PI_WEB_DOCKER_MODE inside PI WEB Docker runtime: $runtime_mode" ;;
+ esac
+ selected_mode=$(docker_mode)
+ [ "$runtime_mode" = "$selected_mode" ] || die "this PI WEB Docker container is in $runtime_mode mode; rerun pi-web-docker with the matching mode flag"
+}
+
+docker_compose() {
+ if docker compose version >/dev/null 2>&1; then
+ docker compose "$@"
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose "$@"
+ else
+ die "Docker Compose is required (docker compose plugin or docker-compose)"
+ fi
+}
+
+require_runtime_compose_assets() {
+ root=$1
+ [ -f "$root/compose.yml" ] || die "runtime compose.yml not found at $root/compose.yml"
+ [ -f "$root/compose.override.yml" ] || die "runtime compose.override.yml not found at $root/compose.override.yml; run pi-web-docker install first"
+ [ -f "$root/.env" ] || die "runtime .env not found at $root/.env; run pi-web-docker install first"
+}
+
+runtime_compose() {
+ root=$(runtime_root)
+ require_runtime_compose_assets "$root"
+ project_name=$(required_env_file_value "$root/.env" COMPOSE_PROJECT_NAME)
+ (
+ cd "$root" || exit 1
+ docker_compose --project-name "$project_name" --env-file .env -f compose.yml -f compose.override.yml "$@"
+ )
+}
+
+dev_compose() {
+ root=$(dev_root)
+ wrapper=$root/docker/internal/dev/compose
+ [ -x "$wrapper" ] || die "dev Compose helper is not executable at $wrapper"
+ (
+ cd "$root" || exit 1
+ PI_WEB_DOCKER_ALLOW_ROOT=$PI_WEB_DOCKER_ALLOW_ROOT "$wrapper" "$@"
+ )
+}
+
+compose_for_install() {
+ case "$(docker_mode)" in
+ runtime) runtime_compose "$@" ;;
+ dev) dev_compose "$@" ;;
+ esac
+}
+
+entrypoint_installer() {
+ installer=$ENTRYPOINT_DIR/install.sh
+ [ -x "$installer" ] || die "installer not found or not executable at $installer"
+ printf '%s\n' "$installer"
+}
+
+runtime_installer() {
+ root=$1
+ installer=$root/install.sh
+ [ -x "$installer" ] || die "runtime installer not found or not executable at $installer; run pi-web-docker install first"
+ printf '%s\n' "$installer"
+}
+
+run_install() {
+ [ "$(docker_mode)" = runtime ] || die "install is only available in runtime mode; omit --dev"
+ installer=$(entrypoint_installer)
+ exec "$installer" "$@"
+}
+
+run_start() {
+ assert_no_args start "$@"
+ require_command docker
+ case "$(docker_mode)" in
+ runtime) runtime_compose up -d ;;
+ dev) dev_compose up -d --build ;;
+ esac
+}
+
+run_stop() {
+ assert_no_args stop "$@"
+ require_command docker
+ compose_for_install down
+}
+
+run_status() {
+ assert_no_args status "$@"
+ require_command docker
+ compose_for_install ps
+}
+
+run_restart_web() {
+ assert_no_args restart-web "$@"
+ compose_for_install restart web
+}
+
+run_restart_sessiond() {
+ assert_no_args restart-sessiond "$@"
+ compose_for_install restart sessiond
+}
+
+run_restart_all() {
+ assert_no_args restart "$@"
+ # Restart web first to mirror native service commands. Detached helpers keep
+ # running after sessiond restarts, so this is safe when launched from PI WEB.
+ compose_for_install restart web sessiond
+}
+
+run_runtime_host_update() {
+ root=$(runtime_root)
+ require_runtime_compose_assets "$root"
+ installer=$(runtime_installer "$root")
+ PI_WEB_DOCKER_REFRESH_ASSETS=1
+ export PI_WEB_DOCKER_REFRESH_ASSETS
+ exec "$installer" --install-dir "$root"
+}
+
+run_update() {
+ assert_no_args update "$@"
+ case "$(docker_mode)" in
+ runtime)
+ if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then
+ run_runtime_host_update
+ fi
+ cache_bust=${CACHE_BUST:-pi-web-docker-$(date -u +%Y%m%dT%H%M%SZ)}
+ log "Building PI WEB runtime image with CACHE_BUST=$cache_bust ..."
+ CACHE_BUST=$cache_bust runtime_compose build --pull --no-cache
+ log "Recreating PI WEB runtime services ..."
+ runtime_compose up -d --force-recreate --remove-orphans
+ ;;
+ dev)
+ log "Rebuilding PI WEB development image ..."
+ dev_compose build --pull
+ log "Recreating PI WEB development services ..."
+ dev_compose up -d --force-recreate --remove-orphans
+ ;;
+ esac
+}
+
+validate_logs_target() {
+ target=${1:-}
+ case "$target" in
+ ""|web|sessiond) return 0 ;;
+ data-init)
+ [ "$(docker_mode)" = dev ] || die "logs data-init is only available with --dev"
+ return 0
+ ;;
+ *) die "logs target must be web, sessiond, or data-init" ;;
+ esac
+}
+
+run_logs() {
+ assert_at_most_one_arg logs "$@"
+ require_command docker
+ target=${1:-}
+ validate_logs_target "$target"
+ if [ -n "$target" ]; then
+ compose_for_install logs -f "$target"
+ else
+ compose_for_install logs -f
+ fi
+}
+
+validate_shell_target() {
+ target=${1:-web}
+ case "$target" in
+ web|sessiond) printf '%s\n' "$target" ;;
+ *) die "shell target must be web or sessiond" ;;
+ esac
+}
+
+run_shell() {
+ assert_at_most_one_arg shell "$@"
+ require_command docker
+ target=$(validate_shell_target "${1:-web}")
+ compose_for_install exec "$target" bash
+}
+
+run_doctor() {
+ assert_no_args doctor "$@"
+ root=$(control_root)
+ printf 'PI WEB Docker mode: %s\n' "$(docker_mode)"
+ printf 'PI WEB Docker root: %s\n' "$root"
+ case "$(docker_mode)" in
+ runtime)
+ [ -f "$root/.env" ] && printf 'Runtime env: %s\n' "$root/.env" || printf 'Runtime env: missing (%s/.env)\n' "$root"
+ [ -f "$root/compose.yml" ] && printf 'Runtime Compose file: %s\n' "$root/compose.yml" || printf 'Runtime Compose file: missing (%s/compose.yml)\n' "$root"
+ [ -f "$root/compose.override.yml" ] && printf 'Runtime Compose override: %s\n' "$root/compose.override.yml" || printf 'Runtime Compose override: missing (%s/compose.override.yml)\n' "$root"
+ [ -x "$root/install.sh" ] && printf 'Runtime installer: %s\n' "$root/install.sh" || printf 'Runtime installer: missing or not executable (%s/install.sh)\n' "$root"
+ ;;
+ dev)
+ dev_config=$root/.pi-web/docker-compose-dev.local.env
+ dev_env=$root/.pi-web/docker-compose-dev.generated.env
+ dev_override=$root/.pi-web/docker-compose-dev.host.generated.yml
+ dev_compose_file=$root/docker/compose.dev.yml
+ dev_wrapper=$root/docker/internal/dev/compose
+ [ -f "$dev_config" ] && printf 'Dev config: %s\n' "$dev_config" || printf 'Dev config: missing (%s)\n' "$dev_config"
+ [ -f "$dev_env" ] && printf 'Generated dev env: %s\n' "$dev_env" || printf 'Generated dev env: missing (%s)\n' "$dev_env"
+ [ -f "$dev_override" ] && printf 'Generated dev Compose override: %s\n' "$dev_override" || printf 'Generated dev Compose override: missing (%s)\n' "$dev_override"
+ [ -f "$dev_compose_file" ] && printf 'Dev Compose file: %s\n' "$dev_compose_file" || printf 'Dev Compose file: missing (%s)\n' "$dev_compose_file"
+ [ -x "$dev_wrapper" ] && printf 'Dev Compose helper: %s\n' "$dev_wrapper" || printf 'Dev Compose helper: missing or not executable (%s)\n' "$dev_wrapper"
+ if [ -f "$dev_env" ]; then
+ dev_uid=$(env_file_value "$dev_env" PI_WEB_UID 2>/dev/null || true)
+ dev_gid=$(env_file_value "$dev_env" PI_WEB_GID 2>/dev/null || true)
+ [ -n "$dev_uid" ] && printf 'Generated dev UID: %s\n' "$dev_uid"
+ [ -n "$dev_gid" ] && printf 'Generated dev GID: %s\n' "$dev_gid"
+ fi
+ ;;
+ esac
+ if command -v docker >/dev/null 2>&1; then
+ docker --version || true
+ if docker compose version >/dev/null 2>&1; then
+ docker compose version || true
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose --version || true
+ else
+ printf '%s\n' 'Docker Compose: not found'
+ fi
+ else
+ printf '%s\n' 'Docker CLI: not found'
+ fi
+}
+
+run_cli() {
+ [ "$#" -gt 0 ] || die "cli requires pi-web arguments"
+ require_command docker
+ compose_for_install exec web pi-web "$@"
+}
+
+current_container_ref() {
+ if [ -n "${PI_WEB_DOCKER_CONTAINER_ID:-}" ]; then
+ printf '%s\n' "$PI_WEB_DOCKER_CONTAINER_ID"
+ return 0
+ fi
+
+ hostname_value=$(hostname 2>/dev/null || true)
+ [ -n "$hostname_value" ] || return 1
+ if docker container inspect "$hostname_value" >/dev/null 2>&1; then
+ printf '%s\n' "$hostname_value"
+ return 0
+ fi
+
+ return 1
+}
+
+helper_image() {
+ env_file=$1
+ case "$(docker_mode)" in
+ runtime)
+ image=$(env_file_value "$env_file" PI_WEB_IMAGE 2>/dev/null || true)
+ [ -n "$image" ] || image=${PI_WEB_IMAGE:-}
+ ;;
+ dev)
+ image=$(env_file_value "$env_file" PI_WEB_DEV_IMAGE 2>/dev/null || true)
+ [ -n "$image" ] || image=${PI_WEB_DEV_IMAGE:-}
+ ;;
+ esac
+
+ if [ -z "${image:-}" ]; then
+ image=${PI_WEB_DOCKER_HELPER_IMAGE:-}
+ fi
+
+ if [ -n "${image:-}" ]; then
+ printf '%s\n' "$image"
+ return 0
+ fi
+
+ container_ref=$(current_container_ref) || die "could not detect this Docker container; set PI_WEB_DOCKER_HELPER_IMAGE explicitly"
+ image=$(docker container inspect "$container_ref" --format '{{.Config.Image}}' 2>/dev/null || true)
+ [ -n "$image" ] && [ "$image" != "" ] || die "could not detect this container's image; set PI_WEB_DOCKER_HELPER_IMAGE explicitly"
+ printf '%s\n' "$image"
+}
+
+control_env_file() {
+ root=$1
+ case "$(docker_mode)" in
+ runtime) candidate=$root/.env ;;
+ dev) candidate=$root/.pi-web/docker-compose-dev.generated.env ;;
+ esac
+ [ -f "$candidate" ] || die "generated $(docker_mode) Docker env not found at $candidate; run pi-web-docker $(mode_flag || true) status or start from the host first"
+ printf '%s\n' "$candidate"
+}
+
+control_root_env_key() {
+ case "$(docker_mode)" in
+ runtime) printf '%s\n' PI_WEB_DOCKER_INSTALL_DIR ;;
+ dev) printf '%s\n' PI_WEB_DOCKER_DEV_REPO_ROOT ;;
+ esac
+}
+
+required_env_file_value() {
+ file=$1
+ key=$2
+ value=$(env_file_value "$file" "$key" 2>/dev/null || true)
+ [ -n "$value" ] || die "generated Docker env $file must define $key for detached helpers"
+ printf '%s\n' "$value"
+}
+
+cleanup_old_helpers() {
+ root=${1:-}
+ project_name=${2:-}
+ base_filters="label=pi-web.docker-helper=true"
+ if [ -n "$root" ] && [ -n "$project_name" ]; then
+ ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter "label=pi-web.docker-helper.project=$project_name" --filter status=exited 2>/dev/null || true)
+ elif [ -n "$root" ]; then
+ ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter status=exited 2>/dev/null || true)
+ else
+ ids=$(docker ps -aq --filter "$base_filters" --filter status=exited 2>/dev/null || true)
+ fi
+ old_ids=$(docker ps -aq --filter label=pi-web.docker-control=true --filter status=exited 2>/dev/null || true)
+ ids="$ids $old_ids"
+ for id in $ids; do
+ [ -n "$id" ] || continue
+ docker rm "$id" >/dev/null 2>&1 || true
+ done
+}
+
+start_detached_helper() {
+ action=$1
+ is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || die "detached helpers are only available inside the PI WEB Docker runtime"
+ require_command docker
+ selected_mode=$(docker_mode)
+ root=$(control_root)
+ env_file=$(control_env_file "$root")
+ root_key=$(control_root_env_key)
+ env_root=$(required_env_file_value "$env_file" "$root_key")
+ [ "$env_root" = "$root" ] || die "generated Docker env $env_file has $root_key=$env_root, but selected $selected_mode root is $root"
+ project_name=$(required_env_file_value "$env_file" COMPOSE_PROJECT_NAME)
+ helper_uid=$(required_env_file_value "$env_file" PI_WEB_UID)
+ helper_gid=$(required_env_file_value "$env_file" PI_WEB_GID)
+ helper_docker_gid=$(required_env_file_value "$env_file" DOCKER_GID)
+ is_unsigned_int "$helper_uid" || die "generated Docker env must define numeric PI_WEB_UID for detached helpers"
+ is_unsigned_int "$helper_gid" || die "generated Docker env must define numeric PI_WEB_GID for detached helpers"
+ is_unsigned_int "$helper_docker_gid" || die "generated Docker env must define numeric DOCKER_GID for detached helpers"
+ if [ "$selected_mode" = dev ] && [ "$helper_uid" -eq 0 ] && [ "$PI_WEB_DOCKER_ALLOW_ROOT" != 1 ]; then
+ die "refusing to start a Docker development helper as root; regenerate dev env with a non-root PI_WEB_UID or retry with --allow-root if intentional"
+ fi
+ helper_user=$helper_uid:$helper_gid
+ helper_group_add=$helper_docker_gid
+ image=$(helper_image "$env_file")
+ container_ref=$(current_container_ref) || die "could not detect this Docker container; set PI_WEB_DOCKER_CONTAINER_ID to enable detached helpers"
+ cleanup_old_helpers "$root" "$project_name"
+
+ timestamp=$(date -u +%Y%m%d%H%M%S)
+ helper_name=pi-web-docker-$action-$timestamp-$$
+ generated_env_keys="PI_WEB_UID PI_WEB_GID DOCKER_GID PI_WEB_DOCKER_HOST_PROFILE HOSTEXEC_MODE PI_WEB_DOCKER_EXTRA_HOST_PATHS PI_WEB_DOCKER_DATA_DIR PI_WEB_DOCKER_INSTALL_DIR PI_WEB_DOCKER_DEV_REPO_ROOT PI_WEB_DOCKER_REF PI_WEB_BIND_ADDR PI_WEB_PORT PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT PI_WEB_VERSION PI_VERSION PI_WEB_OPENSUSE_IMAGE PI_WEB_NODEJS_MAJOR PI_WEB_NODEJS_REPO PI_WEB_EXTRA_ZYPPER_PACKAGES PI_WEB_IMAGE PI_WEB_DEV_IMAGE COMPOSE_PROJECT_NAME HOSTEXEC_IMAGE PI_WEB_MAX_UPLOAD_BYTES"
+
+ set -- run -d \
+ --env-file "$env_file" \
+ --name "$helper_name" \
+ --label pi-web.docker-helper=true \
+ --label "pi-web.docker-helper.action=$action" \
+ --label "pi-web.docker-helper.mode=$selected_mode" \
+ --label "pi-web.docker-helper.root=$root" \
+ --label "pi-web.docker-helper.project=$project_name" \
+ --group-add "$helper_group_add" \
+ --user "$helper_user" \
+ --volumes-from "$container_ref" \
+ --workdir "$root" \
+ --env PI_WEB_DOCKER_RUNTIME=1 \
+ --env "PI_WEB_DOCKER_MODE=$selected_mode" \
+ --env "PI_WEB_DOCKER_ALLOW_ROOT=$PI_WEB_DOCKER_ALLOW_ROOT" \
+ --env "PI_WEB_DOCKER_HELPER_IMAGE=$image" \
+ --env "COMPOSE_PROJECT_NAME=$project_name"
+
+ # Keep --env-file for traceability, then pass parsed values explicitly so
+ # helper process env matches Compose dotenv semantics for quoted values.
+ for key in $generated_env_keys; do
+ if value=$(env_file_value "$env_file" "$key" 2>/dev/null); then
+ set -- "$@" --env "$key=$value"
+ fi
+ done
+
+ case "$selected_mode" in
+ runtime) set -- "$@" --env "PI_WEB_DOCKER_INSTALL_DIR=$root" ;;
+ dev) set -- "$@" --env "PI_WEB_DOCKER_DEV_REPO_ROOT=$root" ;;
+ esac
+ if [ "${CACHE_BUST+x}" = x ]; then
+ set -- "$@" --env "CACHE_BUST=$CACHE_BUST"
+ fi
+ set -- "$@" "$image" pi-web-docker
+
+ flag=$(mode_flag || true)
+ if [ -n "$flag" ]; then
+ set -- "$@" "$flag"
+ fi
+ if [ "$PI_WEB_DOCKER_ALLOW_ROOT" = 1 ]; then
+ set -- "$@" --allow-root
+ fi
+ set -- "$@" __run-detached "$action"
+
+ container_id=$(docker "$@") || die "could not start detached Docker helper"
+ printf 'Started detached PI WEB Docker helper: %s\n' "$helper_name"
+ printf 'Container ID: %s\n' "$container_id"
+ printf 'Follow progress with: docker logs -f %s\n' "$helper_name"
+}
+
+run_detached_action() {
+ action=${1:-}
+ [ "$#" -eq 1 ] || die "__run-detached requires exactly one action"
+ is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || die "detached actions only run inside the PI WEB Docker runtime"
+ require_command docker
+ log "PI WEB Docker helper running action: $action"
+ case "$action" in
+ update) run_update ;;
+ restart) run_restart_all ;;
+ restart-web) run_restart_web ;;
+ restart-sessiond) run_restart_sessiond ;;
+ *) die "unsupported detached action: $action" ;;
+ esac
+ log "PI WEB Docker helper completed action: $action"
+}
+
+run_restart_or_update() {
+ action=$1
+ shift
+ assert_no_args "$action" "$@"
+ if is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then
+ start_detached_helper "$action"
+ return 0
+ fi
+
+ case "$action" in
+ update) run_update ;;
+ restart) run_restart_all ;;
+ restart-web) run_restart_web ;;
+ restart-sessiond) run_restart_sessiond ;;
+ *) die "unsupported action: $action" ;;
+ esac
+}
+
+case "$command_name" in
+ help|-h|--help)
+ usage
+ ;;
+ install)
+ run_install "$@"
+ ;;
+ start|stop|status|logs|shell|doctor|cli|update|restart|restart-web|restart-sessiond|__run-detached)
+ enforce_dev_root_safety
+ enforce_container_mode_match
+ case "$command_name" in
+ start) run_start "$@" ;;
+ stop) run_stop "$@" ;;
+ status) run_status "$@" ;;
+ logs) run_logs "$@" ;;
+ shell) run_shell "$@" ;;
+ doctor) run_doctor "$@" ;;
+ cli) run_cli "$@" ;;
+ update|restart|restart-web|restart-sessiond) run_restart_or_update "$command_name" "$@" ;;
+ __run-detached) run_detached_action "$@" ;;
+ esac
+ ;;
+ *)
+ usage >&2
+ die "unknown command: $command_name"
+ ;;
+esac
diff --git a/docs/install.html b/docs/install.html
index a304eb0..15339a9 100644
--- a/docs/install.html
+++ b/docs/install.html
@@ -92,6 +92,7 @@
RequirementsUser service installOne-line install
+ Docker beta installInstall through PiWSL / manual runRemote access
@@ -139,7 +140,7 @@
One-line install
-
If you prefer a curl pipe, use the repository installer:
+
If you prefer a curl pipe for the native user-service install, use the repository installer. This path still requires Node.js, npm, and Pi Coding Agent on the host.
One-liner
@@ -149,6 +150,27 @@
+
+
Docker beta install
+
+ The beta Docker runtime builds a local image and runs split sessiond + web services. It does not require
+ Node.js or npm on the host; it requires a supported Docker/Compose setup and trusted host paths.
+
+
+
+ Docker one-liner
+
+
+
$ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
+
+
+ After installation, manage it with ~/.local/share/pi-web-docker/pi-web-docker status, update,
+ restart, logs, and related commands. Development mode uses
+ ./docker/pi-web-docker --dev <command> from a checkout.
+
+
Read the Docker guide for the trust model, supported host profiles, command matrix, root-safety notes, and development mode.
+
+
Install through Pi
PI WEB is also published as a Pi package. This exposes a /pi-web command inside Pi.
diff --git a/docs/plugins.md b/docs/plugins.md
index fd7f347..8b0956a 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -190,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le
**Plugin id:** `updates`
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
-Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
+Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. Docker runtimes add a small manifest hint so federated gateways can keep the remote Updates tab visible and expose Docker commands while gateway status parsing catches up. To hide it, disable `updates` in **Settings → Plugins** or set:
```json
{
diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts
index 3266f08..4734749 100644
--- a/pi-web-plugins/updates/pi-web-plugin.ts
+++ b/pi-web-plugins/updates/pi-web-plugin.ts
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebPlugin, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
-import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor } from "./updatesLogic.js";
+import { additionalCommands, fallbackDockerStatus, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor, type UpdatesRuntimeHint } from "./updatesLogic.js";
function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void {
void terminal.runCommand({
@@ -48,6 +48,17 @@ function renderCommand(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal |
`;
}
+function updatesRuntimeHintFromModuleUrl(moduleUrl: string): UpdatesRuntimeHint {
+ try {
+ const dockerMode = new URL(moduleUrl).searchParams.get("piWebDockerMode");
+ return dockerMode === "runtime" || dockerMode === "dev" ? { dockerMode } : {};
+ } catch {
+ return {};
+ }
+}
+
+const runtimeHint = updatesRuntimeHintFromModuleUrl(import.meta.url);
+
function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined {
const recommended = recommendedCommand(status);
const additional = additionalCommands(status, recommended);
@@ -71,7 +82,7 @@ function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal
}
function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
- const status = statusFor(state);
+ const status = statusFor(state) ?? fallbackDockerStatus(runtimeHint);
if (status === undefined) {
return html`
Updates
@@ -157,7 +168,7 @@ const plugin: PiWebPlugin = {
`,
order: 100,
- visible: (context) => shouldShowUpdatesPanel(context.state),
+ visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint),
badge: (context) => {
const count = messageCount(context.state);
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts
index d711a18..1a3066d 100644
--- a/pi-web-plugins/updates/updatesLogic.test.ts
+++ b/pi-web-plugins/updates/updatesLogic.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { PiWebComponentStatus, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
-import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic";
+import { additionalCommands, fallbackDockerStatus, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic";
function component(overrides: Partial = {}): PiWebComponentStatus {
return {
@@ -181,6 +181,11 @@ describe("shouldShowUpdatesPanel", () => {
expect(shouldShowUpdatesPanel(stateWith(value))).toBe(true);
});
+ it("shows the panel when a federated Docker runtime hint is available before status is parsed", () => {
+ expect(shouldShowUpdatesPanel(undefined, { dockerMode: "dev" })).toBe(true);
+ expect(shouldShowUpdatesPanel(undefined, { dockerMode: "runtime" })).toBe(true);
+ });
+
it("hides the panel when status is unavailable", () => {
expect(shouldShowUpdatesPanel(stateWith(undefined))).toBe(false);
expect(shouldShowUpdatesPanel(undefined)).toBe(false);
@@ -223,6 +228,24 @@ describe("shouldShowUpdatesPanel", () => {
});
});
+describe("fallbackDockerStatus", () => {
+ it("creates Docker development commands from a federated runtime hint", () => {
+ const fallback = fallbackDockerStatus({ dockerMode: "dev" }, "generated");
+ expect(fallback?.generatedAt).toBe("generated");
+ expect(fallback?.components.web.installation).toEqual({ kind: "docker", dockerMode: "dev" });
+ expect(fallback?.commands).toMatchObject({
+ update: "pi-web-docker --dev update",
+ restart: "pi-web-docker --dev restart",
+ status: "pi-web-docker --dev status",
+ });
+ expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility");
+ });
+
+ it("does not create a fallback without a Docker runtime hint", () => {
+ expect(fallbackDockerStatus({})).toBeUndefined();
+ });
+});
+
describe("messageCount", () => {
it("counts messages and tolerates missing status", () => {
expect(messageCount(undefined)).toBe(0);
diff --git a/pi-web-plugins/updates/updatesLogic.ts b/pi-web-plugins/updates/updatesLogic.ts
index 459ff32..8914914 100644
--- a/pi-web-plugins/updates/updatesLogic.ts
+++ b/pi-web-plugins/updates/updatesLogic.ts
@@ -1,10 +1,14 @@
-import type { PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
+import type { PiWebDockerMode, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
export interface CommandEntry {
label: string;
command: string;
}
+export interface UpdatesRuntimeHint {
+ dockerMode?: PiWebDockerMode;
+}
+
// The single command users should run when they do not want to think: if an
// update is available, `commands.update` already chains the update and a full
// restart; otherwise, when anything is stale, a full restart is enough.
@@ -49,14 +53,43 @@ export function isSelfManagedInstallation(installation: PiWebInstallationInfo |
return installation === undefined || installation.kind === "local" || installation.kind === "docker" || installation.kind === "unknown";
}
-export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
+export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined, hint: UpdatesRuntimeHint = {}): boolean {
const status = statusFor(state);
+ if (hint.dockerMode !== undefined) return true;
if (messageCount(state) > 0) return true;
if (status === undefined) return false;
return isSelfManagedInstallation(status.components.web.installation)
|| isSelfManagedInstallation(status.components.sessiond.installation);
}
+export function fallbackDockerStatus(hint: UpdatesRuntimeHint, generatedAt = "federated status unavailable"): PiWebStatusResponse | undefined {
+ if (hint.dockerMode === undefined) return undefined;
+ const commandPrefix = hint.dockerMode === "dev" ? "pi-web-docker --dev" : "pi-web-docker";
+ const installation: PiWebInstallationInfo = { kind: "docker", dockerMode: hint.dockerMode };
+ return {
+ packageName: "@jmfederico/pi-web",
+ generatedAt,
+ components: {
+ web: { component: "web", label: "Web/UI", stale: false, available: true, installation },
+ sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true, installation },
+ },
+ release: { packageName: "@jmfederico/pi-web", updateAvailable: false, skipped: true },
+ commands: {
+ update: `${commandPrefix} update`,
+ restart: `${commandPrefix} restart`,
+ restartWeb: `${commandPrefix} restart-web`,
+ restartSessiond: `${commandPrefix} restart-sessiond`,
+ status: `${commandPrefix} status`,
+ },
+ messages: [{
+ id: "docker-status-compatibility",
+ severity: "info",
+ title: "Docker update commands available",
+ body: "This Updates plugin was loaded from a Docker PI WEB runtime, but the gateway has not provided Docker-aware status details yet. The Docker maintenance commands below are still available.",
+ }],
+ };
+}
+
export function formatVersion(version: string | undefined): string {
return version === undefined || version === "" ? "unknown" : version;
}
diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts
index 7b4ec66..754f560 100644
--- a/src/server/piWebPluginService.test.ts
+++ b/src/server/piWebPluginService.test.ts
@@ -6,11 +6,20 @@ import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService
let tempDir: string;
+const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
+const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
+const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
+const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
+
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-plugin-service-test-"));
});
afterEach(async () => {
+ restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
+ restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
+ restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
+ restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
await rm(tempDir, { recursive: true, force: true });
});
@@ -47,6 +56,20 @@ describe("PiWebPluginService", () => {
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
});
+ it("adds Docker runtime hints to the Updates plugin module URL", async () => {
+ process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
+ process.env["PI_WEB_DOCKER_MODE"] = "dev";
+ await writePlugin(join(tempDir, "plugins", "updates"), {
+ packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
+ files: { "pi-web-plugin.js": "export default {};" },
+ });
+
+ const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
+
+ const manifest = await service.manifest();
+ expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/updates\/pi-web-plugin\.js\?v=\d+&piWebDockerMode=dev$/u);
+ });
+
it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, {
@@ -198,3 +221,8 @@ async function writePlugin(root: string, options: { packageJson: unknown; files:
await writeFile(filePath, content);
}
}
+
+function restoreEnv(key: string, value: string | undefined): void {
+ if (value === undefined) Reflect.deleteProperty(process.env, key);
+ else process.env[key] = value;
+}
diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts
index b952d53..97061d9 100644
--- a/src/server/piWebPluginService.ts
+++ b/src/server/piWebPluginService.ts
@@ -135,7 +135,7 @@ export class PiWebPluginService {
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
return {
id: plugin.id,
- module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
+ module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`,
source: plugin.source,
scope: plugin.scope,
machineSpecific: plugin.machineSpecific,
@@ -187,6 +187,35 @@ function bundledPluginRoot(packageRoot: string): string {
return join(packageRoot, "dist", "pi-web-plugins");
}
+function pluginModuleQuery(plugin: PluginRecord): string {
+ const params = new URLSearchParams({ v: plugin.version });
+ const dockerMode = plugin.id === "updates" ? dockerModeFromEnv() : undefined;
+ if (dockerMode !== undefined) params.set("piWebDockerMode", dockerMode);
+ return params.toString();
+}
+
+function dockerModeFromEnv(): "runtime" | "dev" | undefined {
+ if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
+ const mode = process.env["PI_WEB_DOCKER_MODE"];
+ if (mode === "runtime" || mode === "dev") return mode;
+ if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
+ if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
+ return undefined;
+}
+
+function firstNonEmptyEnv(...keys: string[]): string | undefined {
+ for (const key of keys) {
+ const value = process.env[key];
+ if (value !== undefined && value !== "") return value;
+ }
+ return undefined;
+}
+
+function isTruthyEnv(key: string): boolean {
+ const value = process.env[key];
+ return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
+}
+
function sourceCheckoutPluginRoots(cwd: string): LocalPluginRoot[] {
const pluginsRoot = join(cwd, "plugins");
if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];
From 14d0b0f181547bf126e96ed9ea133bd279724396 Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Thu, 2 Jul 2026 20:47:39 +0000
Subject: [PATCH 18/20] docs: keep Docker setup docs scoped
---
.changeset/docker-beta-runtime-dev.md | 5 -----
.changeset/docker-updates-tab.md | 5 -----
README.md | 8 --------
docs/install.html | 22 ----------------------
docs/plugins.html | 14 ++++++--------
docs/plugins.md | 16 ++++++++--------
src/docker/piWebDockerDocs.test.ts | 26 ++++++++++++++++++--------
7 files changed, 32 insertions(+), 64 deletions(-)
delete mode 100644 .changeset/docker-beta-runtime-dev.md
delete mode 100644 .changeset/docker-updates-tab.md
diff --git a/.changeset/docker-beta-runtime-dev.md b/.changeset/docker-beta-runtime-dev.md
deleted file mode 100644
index a931c0a..0000000
--- a/.changeset/docker-beta-runtime-dev.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Add a beta Docker runtime and development setup with a single `pi-web-docker` command surface, a production one-line install that does not require host Node.js, local image builds, split session/web services, profile-specific host access for native Linux and Docker Desktop for Mac, shared persistent data, custom image hooks, and Docker usage documentation.
diff --git a/.changeset/docker-updates-tab.md b/.changeset/docker-updates-tab.md
deleted file mode 100644
index dd83d19..0000000
--- a/.changeset/docker-updates-tab.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Expose Docker-aware PI WEB status, update, and restart commands in the Updates panel through the canonical `pi-web-docker` command, keep the Updates tab visible across federated Docker runtimes, including Docker development runtimes with explicit `pi-web-docker --dev ...` commands, and harden production and development Docker workflows around generated Compose assets, Compose project-name isolation, clearer checkout/runtime guidance, root-safety checks, UID/GID preservation, and detached helper execution.
diff --git a/README.md b/README.md
index e1d06bb..c1b2f30 100644
--- a/README.md
+++ b/README.md
@@ -80,14 +80,6 @@ pi install npm:@jmfederico/pi-web
In Pi, use `/pi-web install`, `/pi-web status`, `/pi-web logs`, `/pi-web restart`, `/pi-web doctor`, and `/pi-web version`.
-Docker beta runtime/server install is available when you want a local image and do not want Node.js or npm on the host:
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
-```
-
-See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust model, supported host profiles, commands, and development mode.
-
## Core model
PI WEB organizes work like this:
diff --git a/docs/install.html b/docs/install.html
index 15339a9..cb13cf0 100644
--- a/docs/install.html
+++ b/docs/install.html
@@ -92,7 +92,6 @@
RequirementsUser service installOne-line install
- Docker beta installInstall through PiWSL / manual runRemote access
@@ -150,27 +149,6 @@
-
-
Docker beta install
-
- The beta Docker runtime builds a local image and runs split sessiond + web services. It does not require
- Node.js or npm on the host; it requires a supported Docker/Compose setup and trusted host paths.
-
-
-
- Docker one-liner
-
-
-
$ curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
-
-
- After installation, manage it with ~/.local/share/pi-web-docker/pi-web-docker status, update,
- restart, logs, and related commands. Development mode uses
- ./docker/pi-web-docker --dev <command> from a checkout.
-
-
Read the Docker guide for the trust model, supported host profiles, command matrix, root-safety notes, and development mode.
-
-
Install through Pi
PI WEB is also published as a Pi package. This exposes a /pi-web command inside Pi.
diff --git a/docs/plugins.html b/docs/plugins.html
index 1f3403e..18ba083 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -212,9 +212,7 @@ After editing, check the manifest endpoint and browser-console failure cases.
Updates adds a conditional Updates workspace tab with PI WEB update,
restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
- selected machine's plugin copy when machine federation is active. Docker runtimes also publish a small
- manifest hint so federated gateways can keep the remote Updates tab visible and expose Docker commands
- while gateway status parsing catches up.
+ selected machine's plugin copy when machine federation is active.
Plugin id: updates
@@ -260,11 +258,11 @@ After editing, check the manifest endpoint and browser-console failure cases. {
pending.status = "ready";
pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1];
diff --git a/src/docker/piWebDockerDocs.test.ts b/src/docker/piWebDockerDocs.test.ts
index 4d1b916..979e15e 100644
--- a/src/docker/piWebDockerDocs.test.ts
+++ b/src/docker/piWebDockerDocs.test.ts
@@ -8,17 +8,27 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const dockerOneLine = "curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh";
describe("pi-web-docker documentation", () => {
- it("documents the Docker one-line install in the Docker guide, root README, and install page", async () => {
- const [dockerReadme, rootReadme, installPage] = await Promise.all([
- readRepoFile("docker/README.md"),
- readRepoFile("README.md"),
- readRepoFile("docs/install.html"),
- ]);
+ it("documents the Docker one-line install in the Docker guide", async () => {
+ const dockerReadme = await readRepoFile("docker/README.md");
expect(dockerReadme).toContain(dockerOneLine);
expect(dockerReadme).toContain("does not require Node.js or npm on the host");
- expect(rootReadme).toContain(dockerOneLine);
- expect(installPage).toContain(dockerOneLine);
+ });
+
+ it("keeps Docker setup documentation scoped to the Docker folder", async () => {
+ const nonDockerDocs = await Promise.all([
+ readRepoFile("README.md"),
+ readRepoFile("docs/install.html"),
+ readRepoFile("docs/plugins.md"),
+ readRepoFile("docs/plugins.html"),
+ ]);
+
+ for (const content of nonDockerDocs) {
+ expect(content).not.toContain(dockerOneLine);
+ expect(content).not.toContain("pi-web-docker");
+ expect(content).not.toContain("Docker beta");
+ expect(content).not.toContain("Docker guide");
+ }
});
it("keeps the Docker command matrix aligned with the canonical user command surface", async () => {
From fb585261a321c627d5d836c16be99ff745c1e4a1 Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Thu, 2 Jul 2026 21:39:29 +0000
Subject: [PATCH 19/20] docs: keep README focused on quick start
---
README.md | 32 --------------------------------
1 file changed, 32 deletions(-)
diff --git a/README.md b/README.md
index 165d27d..fbbc619 100644
--- a/README.md
+++ b/README.md
@@ -66,20 +66,6 @@ pi-web uninstall
For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install).
-Common alternatives:
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh
-```
-
-PI WEB is also published as a Pi package:
-
-```bash
-pi install npm:@jmfederico/pi-web
-```
-
-In Pi, use `/pi-web install`, `/pi-web status`, `/pi-web logs`, `/pi-web restart`, `/pi-web doctor`, and `/pi-web version`.
-
## Core model
PI WEB organizes work like this:
@@ -169,24 +155,6 @@ npm run dev:web
npm run dev:client
```
-Or install the split development setup as native per-user services from the checkout:
-
-```bash
-pi-web install --dev
-```
-
-`pi-web install --dev` writes the session daemon plus a UI development service using the native user-service backend. `pi-web uninstall` removes both production and development service files; no uninstall flags are needed.
-
-`dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions.
-
-For a production-style run from a checkout:
-
-```bash
-npm run build
-npm run start:sessiond
-PI_WEB_PORT=8504 npm start
-```
-
Validate changes with:
```bash
From 031aee3efe5b0a4fd99db8cc97e947b1450d8bae Mon Sep 17 00:00:00 2001
From: Pi Web Agent
Date: Thu, 2 Jul 2026 21:52:24 +0000
Subject: [PATCH 20/20] test: skip Docker command executions on Windows
---
src/server/dockerControlAssets.test.ts | 30 +++++++++++++++-----------
1 file changed, 17 insertions(+), 13 deletions(-)
diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts
index e044e5e..00febfa 100644
--- a/src/server/dockerControlAssets.test.ts
+++ b/src/server/dockerControlAssets.test.ts
@@ -30,6 +30,10 @@ afterEach(async () => {
});
describe("Docker command assets", () => {
+ // The Docker control shell scripts intentionally support Linux and macOS hosts.
+ // Keep Windows CI on static/syntax coverage instead of executing POSIX host-path and socket flows there.
+ const dockerCommandIt = it.skipIf(process.platform === "win32");
+
it("keeps shell entrypoints syntactically valid", async () => {
await Promise.all([
execUtf8("sh", ["-n", dockerEntrypoint], process.env),
@@ -78,7 +82,7 @@ describe("Docker command assets", () => {
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
});
- it("runs status through Docker Compose in the foreground", async () => {
+ dockerCommandIt("runs status through Docker Compose in the foreground", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
@@ -91,7 +95,7 @@ describe("Docker command assets", () => {
expect(log).not.toContain("run -d");
});
- it("runs production host lifecycle commands through the generated runtime env", async () => {
+ dockerCommandIt("runs production host lifecycle commands through the generated runtime env", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
const env = runtimeHostEnv(fakeDocker, installDir);
@@ -113,7 +117,7 @@ describe("Docker command assets", () => {
expect(log).not.toContain("run -d");
});
- it("ignores ambient Compose project names for runtime lifecycle commands", async () => {
+ dockerCommandIt("ignores ambient Compose project names for runtime lifecycle commands", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
@@ -127,7 +131,7 @@ describe("Docker command assets", () => {
expect(log).not.toContain("--project-name ambient-project");
});
- it("runs development commands through generated env while preserving host user ids", async () => {
+ dockerCommandIt("runs development commands through generated env while preserving host user ids", async () => {
const devRoot = await createDevRepoFixture();
const fakeDocker = await installFakeDocker();
await installFakeUname(fakeDocker.binDir, "Darwin");
@@ -181,7 +185,7 @@ describe("Docker command assets", () => {
expect(log).toContain(`compose --project-name pi-web-dev --env-file ${generatedEnvPath} -f ${devRoot}/docker/compose.dev.yml -f ${devRoot}/.pi-web/docker-compose-dev.host.generated.yml ps`);
});
- it("rejects development commands as root unless explicitly allowed", async () => {
+ dockerCommandIt("rejects development commands as root unless explicitly allowed", async () => {
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 0, 0);
@@ -194,7 +198,7 @@ describe("Docker command assets", () => {
expect(result.stderr).toContain("refusing to run Docker development mode as root");
});
- it("passes the root override through to the development compose helper", async () => {
+ dockerCommandIt("passes the root override through to the development compose helper", async () => {
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 0, 0);
const helperLog = join(tempDir, "dev-helper.log");
@@ -209,7 +213,7 @@ describe("Docker command assets", () => {
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
});
- it("starts development detached helpers as the generated dev user", async () => {
+ dockerCommandIt("starts development detached helpers as the generated dev user", async () => {
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
@@ -230,7 +234,7 @@ describe("Docker command assets", () => {
expect(log).not.toContain("--user 0:0");
});
- it("rejects inside-container commands whose explicit mode does not match the container mode", async () => {
+ dockerCommandIt("rejects inside-container commands whose explicit mode does not match the container mode", async () => {
const result = await runDockerCommandAllowFailure(["restart-sessiond"], {
...cleanProcessEnv(),
PI_WEB_DOCKER_RUNTIME: "1",
@@ -241,13 +245,13 @@ describe("Docker command assets", () => {
expect(result.stderr).toContain("this PI WEB Docker container is in dev mode");
});
- it("routes production install to the bootstrap installer", async () => {
+ dockerCommandIt("routes production install to the bootstrap installer", async () => {
const result = await runDockerCommand(["install", "--help"], cleanProcessEnv());
expect(result.stdout).toContain("Usage: docker/install.sh [options]");
});
- it("explains source checkout runtime-mode mistakes", async () => {
+ dockerCommandIt("explains source checkout runtime-mode mistakes", async () => {
const fakeDocker = await installFakeDocker();
const result = await runDockerCommandAllowFailure(["start"], {
@@ -264,7 +268,7 @@ describe("Docker command assets", () => {
expect(result.stderr).toContain("PI_WEB_DOCKER_INSTALL_DIR");
});
- it("starts restart-sessiond in a detached Docker helper", async () => {
+ dockerCommandIt("starts restart-sessiond in a detached Docker helper", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
@@ -296,7 +300,7 @@ describe("Docker command assets", () => {
expect(log).not.toContain("compose -f compose.yml -f compose.override.yml restart sessiond");
});
- it("executes the detached restart-sessiond action through Compose", async () => {
+ dockerCommandIt("executes the detached restart-sessiond action through Compose", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
@@ -307,7 +311,7 @@ describe("Docker command assets", () => {
expect(log).not.toContain("run -d");
});
- it("executes the detached runtime update action through Compose without nesting helpers", async () => {
+ dockerCommandIt("executes the detached runtime update action through Compose without nesting helpers", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();