Merge pull request #42 from jmfederico/feat/docker-runtime-host-admin

Add Docker runtime and development setup
This commit is contained in:
Federico Jaramillo Martinez
2026-07-02 23:55:20 +02:00
committed by GitHub
38 changed files with 4489 additions and 35 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
node_modules
dist
.pi-web
.playwright-cli
dev-plugins
*.log
.env
.DS_Store
docker/custom-image.d/*
!docker/custom-image.d/.gitkeep
!docker/custom-image.d/*.sh
+4
View File
@@ -8,5 +8,9 @@ dist/
# Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/<plugin-id>.
/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/
+12
View File
@@ -0,0 +1,12 @@
# Keep the local-build runtime context small and avoid sending persistent data.
*
!Dockerfile
!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
+80
View File
@@ -0,0 +1,80 @@
# syntax=docker/dockerfile:1.7
ARG OPENSUSE_IMAGE=opensuse/tumbleweed
ARG DOCKER_CLI_VERSION=29-cli
FROM docker:${DOCKER_CLI_VERSION} AS docker-cli
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"]
ENV NPM_CONFIG_UPDATE_NOTIFIER=false \
SHELL=/bin/bash \
TERM=xterm-256color
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
FROM base AS package
ARG PI_WEB_VERSION=latest
ARG PI_VERSION=latest
ARG CACHE_BUST=local
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 base 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 \
HOSTEXEC_IMAGE=alpine:3.22 \
SHELL=/bin/bash \
TERM=xterm-256color
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 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 '\
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; \
zypper clean --all; \
rm -rf /var/cache/zypp/* \
'
WORKDIR /workspace
USER pi-web
EXPOSE 8504
ENTRYPOINT ["tini", "--"]
CMD ["pi-web-server"]
+66
View File
@@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1.7
ARG OPENSUSE_IMAGE=opensuse/tumbleweed
ARG DOCKER_CLI_VERSION=29-cli
FROM docker:${DOCKER_CLI_VERSION} AS docker-cli
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"]
ENV NODE_ENV=development \
PATH=/workspace/node_modules/.bin:$PATH \
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
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
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 \
&& chmod -R a+rwX /workspace/node_modules /data \
&& 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/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 '\
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; \
zypper clean --all; \
rm -rf /var/cache/zypp/* \
'
EXPOSE 8504 8505
ENTRYPOINT ["tini", "--"]
CMD ["npm", "run", "dev"]
+365
View File
@@ -0,0 +1,365 @@
# 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:
- **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. 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
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 host paths:
- `/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.
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:
- 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.
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. After installation, use the canonical runtime command in the install directory, for example `~/.local/share/pi-web-docker/pi-web-docker update`.
Defaults:
- install directory: `~/.local/share/pi-web-docker` (or `$XDG_DATA_HOME/pi-web-docker`);
- persistent data: `<install-dir>/data`, mounted at `/data`;
- browser URL: <http://127.0.0.1:8504>;
- 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.
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.
### Command matrix
From a production/runtime install directory, run `./pi-web-docker <command>`. From a checkout, run `./docker/pi-web-docker --dev <command>` 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`.
| 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 <pi-web args...>` | `./docker/pi-web-docker --dev cli <pi-web args...>` | 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.
### 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 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 |
| `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 |
| `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. `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
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:
```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
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 `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
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
```
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:
```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=<git-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 [--root] <command...>` 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.
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
hostexec systemctl status docker
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:
```bash
cd ~/.local/share/pi-web-docker
docker compose exec web hostexec uname -a
```
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
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`.
From the repository root, use the canonical Docker command so the same fail-closed host profile detection is applied as runtime mode:
```bash
./docker/pi-web-docker --dev start
```
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 command are resolved in this order:
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.
`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`:
```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 command:
```bash
PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 \
PI_WEB_DEV_BIND_ADDR=0.0.0.0 \
./docker/pi-web-docker --dev start
```
You can run the dev stack in the background with:
```bash
./docker/pi-web-docker --dev start
```
Open the Vite UI at <http://127.0.0.1:8505>. The dev API is published on <http://127.0.0.1:8504>.
Useful development commands:
```bash
./docker/pi-web-docker --dev status
./docker/pi-web-docker --dev logs web
./docker/pi-web-docker --dev logs data-init
./docker/pi-web-docker --dev restart-web
./docker/pi-web-docker --dev restart-sessiond
./docker/pi-web-docker --dev update
./docker/pi-web-docker --dev stop
```
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. Commands launched from the Updates panel use the same detached `pi-web-docker` helper as runtime mode, so update/restart work continues after the current PI WEB terminal or container exits. In both modes detached helpers load the generated Docker env and run as the generated `PI_WEB_UID:PI_WEB_GID` with the generated Docker group; development helpers still refuse UID 0 unless `--allow-root` is explicit.
The dev setup intentionally has the same Docker socket and profile-specific host mounts as the runtime setup. The same trust warnings apply. The command refuses to run development mode as UID 0, or to generate a dev env with `PI_WEB_UID=0`, unless you pass `--allow-root`; use that override only when root-owned checkout writes are intentional.
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
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 Linux, prefer host-mounted paths such as `/home/core/<repo>`, `/srv/<project>`, or `/opt/<project>`. On Mac, prefer paths under `/Users/<you>/...`. 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/pi-web-docker --dev stop
docker volume rm pi-web-dev_node_modules
./docker/pi-web-docker --dev start
```
## 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 after generating host overrides:
```bash
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/internal/dev/compose config
docker build --check -f docker/Dockerfile docker
docker build --check -f docker/Dockerfile.dev .
```
+119
View File
@@ -0,0 +1,119 @@
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:-}
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
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}
HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled}
PI_WEB_UID: ${PI_WEB_UID:-1000}
PI_WEB_GID: ${PI_WEB_GID:-1000}
DOCKER_GID: ${DOCKER_GID:-0}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
PI_WEB_DOCKER_RUNTIME: "1"
PI_WEB_DOCKER_MODE: dev
PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}
PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}
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: ..
target: /workspace
- type: volume
source: node_modules
target: /workspace/node_modules
- *pi-web-dev-data-volume
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}"
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:
node_modules:
+82
View File
@@ -0,0 +1,82 @@
name: pi-web
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_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}
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
HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
PI_WEB_DOCKER_RUNTIME: "1"
PI_WEB_DOCKER_MODE: runtime
PI_WEB_DOCKER_INSTALL_DIR: ${PI_WEB_DOCKER_INSTALL_DIR:?set by docker/install.sh}
PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_IMAGE:-pi-web:local}
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web}
x-pi-web-volumes: &pi-web-volumes
- type: bind
source: ${PI_WEB_DOCKER_DATA_DIR:-./data}
target: /data
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}"
security_opt:
- label=disable
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}"
security_opt:
- label=disable
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
View File
+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
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)
--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
-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:
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
;;
--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
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"
}
dotenv_quote() {
value=$1
[ -n "$value" ] || return 0
printf '"%s"' "$(printf '%s' "$value" | sed 's/[\\"]/\\&/g')"
}
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() {
pi_web_docker_compose "$@"
}
run_runtime_compose() {
compose_cmd --project-name "$compose_project_name" --env-file .env -f compose.yml -f compose.override.yml "$@"
}
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
asset_ref=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_REF main)
asset_base=${PI_WEB_DOCKER_ASSET_BASE:-https://raw.githubusercontent.com/jmfederico/pi-web/$asset_ref/docker}
use_local_asset_dir=1
if [ "${PI_WEB_DOCKER_REFRESH_ASSETS:-0}" = 1 ] || [ "${PI_WEB_DOCKER_REF+x}" = x ] || [ "${PI_WEB_DOCKER_ASSET_BASE+x}" = x ]; then
use_local_asset_dir=0
fi
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 [ "$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_dir=
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/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/internal/host-profile.sh" "$profile_helper_temp"
profile_helper=$profile_helper_temp
fi
# shellcheck source=internal/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 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"
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 "$(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"
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_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)
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 "")
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_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"
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 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" "$install_dir"; then
die "could not write host-specific Compose override"
fi
umask 077
temp_env=$env_file.$$
cat >"$temp_env" <<EOF
# Generated by the PI WEB Docker installer.
# Re-run install.sh to refresh Docker assets and update the local image.
# Persistent data lives in PI_WEB_DOCKER_DATA_DIR and is not deleted by updates.
# Host identity used for the runtime containers and image user account.
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, 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
# npm version pins. Use latest for quick updates, or set concrete versions.
PI_WEB_VERSION=$pi_web_version
PI_VERSION=$pi_version
# openSUSE/Node.js image build inputs.
PI_WEB_OPENSUSE_IMAGE=$pi_web_opensuse_image
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, 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
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"
if [ "${PI_WEB_DOCKER_SKIP_COMPOSE:-0}" = 1 ]; then
log "Skipping Docker build/recreate because PI_WEB_DOCKER_SKIP_COMPOSE=1"
exit 0
fi
if ! command -v docker >/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 run_runtime_compose build --pull --no-cache
)
log "Recreating split PI WEB Docker services ..."
(
cd "$install_dir"
run_runtime_compose 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, run: $install_dir/pi-web-docker update"
(
cd "$install_dir"
run_runtime_compose ps
)
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage: hostexec [--root] [--] <command...>
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
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
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}"
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 \
"${docker_args[@]}" \
--env HOSTEXEC_TARGET_UID="$target_uid" \
--env HOSTEXEC_TARGET_GID="$target_gid" \
"$helper_image" \
nsenter -t 1 -m -u -i -n -p -- /bin/sh -c "$run_as_container_user" hostexec-user "$@"
+313
View File
@@ -0,0 +1,313 @@
#!/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=../host-profile.sh
# shellcheck disable=SC1091
. "$repo_root/docker/internal/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"
}
generated_env_value() {
env_file_value "$generated_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/pi-web-docker --dev creates this file once and does not
# overwrite it. Put persistent dev Docker settings here.
#
# 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
#
# 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"
#
# 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"
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_config_or_generated_or_env_or_runtime_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}\""
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"
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
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_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" "$repo_root"; then
die "could not write host-specific Compose override"
fi
umask 077
temp_env=$env_file.$$
cat >"$temp_env" <<EOF
# Generated by docker/pi-web-docker --dev. Do not edit by hand.
PI_WEB_UID=$pi_web_uid
PI_WEB_GID=$pi_web_gid
DOCKER_GID=$docker_gid
PI_WEB_DOCKER_DATA_DIR=$pi_web_data_dir
PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root
PI_WEB_DOCKER_HOST_PROFILE=$PI_WEB_DETECTED_DOCKER_HOST_PROFILE
HOSTEXEC_MODE=$PI_WEB_DETECTED_HOSTEXEC_MODE
PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths
PI_WEB_OPENSUSE_IMAGE=$pi_web_opensuse_image
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
PI_WEB_DEV_IMAGE=$pi_web_dev_image
COMPOSE_PROJECT_NAME=$compose_project_name
HOSTEXEC_IMAGE=$hostexec_image
PI_WEB_MAX_UPLOAD_BYTES=$pi_web_max_upload_bytes
PI_WEB_DEV_API_BIND_ADDR=$pi_web_dev_api_bind_addr
PI_WEB_DEV_BIND_ADDR=$pi_web_dev_bind_addr
PI_WEB_DEV_API_PORT=$pi_web_dev_api_port
PI_WEB_DEV_PORT=$pi_web_dev_port
EOF
mv "$temp_env" "$env_file"
log "Selected PI WEB Docker host profile: $PI_WEB_DETECTED_DOCKER_HOST_PROFILE"
if [ -f "$runtime_env_file" ]; then
log "Reused runtime Docker environment defaults from: $runtime_env_file"
fi
case "$PI_WEB_DETECTED_DOCKER_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 "User-editable dev config: $dev_config_file"
log "Generated dev env: $env_file"
log "Generated dev Compose override: $override_file"
if [ "$#" -eq 0 ]; then
set -- up --build
fi
pi_web_docker_compose \
--project-name "$compose_project_name" \
--env-file "$env_file" \
-f "$repo_root/docker/compose.dev.yml" \
-f "$override_file" \
"$@"
+365
View File
@@ -0,0 +1,365 @@
#!/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:-}
control_path=${4:-}
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" <<EOF
# Generated by PI WEB Docker host profile detection. Do not edit by hand.
# Re-run the installer or docker/pi-web-docker --dev to refresh this file.
x-pi-web-host-volumes: &pi-web-host-volumes
EOF
socket_source=${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-/var/run/docker.sock}
pi_web_docker_host_write_volume "$socket_source" /var/run/docker.sock false
case "$host_profile" in
linux-native-docker)
pi_web_docker_host_write_existing_volume /home /home false
pi_web_docker_host_write_existing_volume /srv /srv false
pi_web_docker_host_write_existing_volume /opt /opt false
pi_web_docker_host_write_volume / /host true
;;
mac-docker-desktop)
pi_web_docker_host_write_existing_volume /Users /Users false
pi_web_docker_host_write_existing_volume /Volumes /Volumes false
pi_web_docker_host_write_existing_volume /private /private false
;;
esac
if ! pi_web_docker_host_write_extra_volumes "$extra_paths"; then
rm -f "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
return 1
fi
if [ -n "$control_path" ]; then
if [ ! -e "$control_path" ]; then
printf '%s\n' "PI WEB Docker control path does not exist: $control_path" >&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" <<EOF
services:
sessiond:
environment:
HOSTEXEC_MODE: $hostexec_mode
volumes: *pi-web-host-volumes
web:
environment:
HOSTEXEC_MODE: $hostexec_mode
volumes: *pi-web-host-volumes
EOF
mv "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" "$target_file"
}
pi_web_docker_host_print_detection_failure() {
printf '%s\n' "PI WEB Docker setup could not determine a supported host profile." >&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
}
+161
View File
@@ -0,0 +1,161 @@
#!/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:-}
runtime_uid=${PI_WEB_UID:-1000}
runtime_gid=${PI_WEB_GID:-1000}
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
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
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
groupmod --gid "$runtime_gid" "$runtime_group"
else
groupadd --gid "$runtime_gid" "$runtime_group"
fi
if id "$runtime_user" >/dev/null 2>&1; then
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" --no-create-home --home-dir "$runtime_home" --shell /bin/bash "$runtime_user"
fi
chown -R "$runtime_uid:$runtime_gid" /data /workspace
zypper clean --all
rm -rf /var/cache/zypp/*
+741
View File
@@ -0,0 +1,741 @@
#!/usr/bin/env sh
set -eu
log() {
printf '%s\n' "$*" >&2
}
die() {
log "pi-web-docker: $*"
exit 1
}
usage() {
cat <<'EOF'
Usage: pi-web-docker [--dev] [--allow-root] <command> [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 <pi-web args...> 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
}
is_checkout_runtime_default_root() {
root=$1
[ -z "${PI_WEB_DOCKER_INSTALL_DIR:-}" ] || return 1
[ -f "$root/compose.dev.yml" ] || return 1
[ -f "$root/../package.json" ] || return 1
[ -f "$root/pi-web-docker" ] || return 1
}
runtime_command_hint() {
command=${command_name:-status}
printf '%s\n' "$command"
}
default_runtime_entrypoint_hint() {
if [ -n "${XDG_DATA_HOME:-}" ]; then
printf '%s\n' "$XDG_DATA_HOME/pi-web-docker/pi-web-docker"
elif [ -n "${HOME:-}" ]; then
printf '%s\n' "$HOME/.local/share/pi-web-docker/pi-web-docker"
else
printf '%s\n' '~/.local/share/pi-web-docker/pi-web-docker'
fi
}
die_missing_runtime_asset() {
root=$1
missing_path=$2
if is_checkout_runtime_default_root "$root"; then
command_hint=$(runtime_command_hint)
runtime_entrypoint=$(default_runtime_entrypoint_hint)
log "pi-web-docker: runtime install assets were not found in $root."
log "Missing generated asset: $missing_path"
log ""
log "You appear to be running this checkout's Docker command in runtime mode."
log "For development, use:"
log ""
log " ./docker/pi-web-docker --dev $command_hint"
log ""
log "For an installed runtime, use the installed command, usually:"
log ""
log " $runtime_entrypoint $command_hint"
log ""
log "Or set PI_WEB_DOCKER_INSTALL_DIR to your runtime install directory."
exit 1
fi
die "runtime install asset not found at $missing_path; run pi-web-docker install first"
}
require_runtime_compose_assets() {
root=$1
[ -f "$root/compose.yml" ] || die_missing_runtime_asset "$root" "$root/compose.yml"
[ -f "$root/compose.override.yml" ] || die_missing_runtime_asset "$root" "$root/compose.override.yml"
[ -f "$root/.env" ] || die_missing_runtime_asset "$root" "$root/.env"
}
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" != "<no value>" ] || 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
+1 -1
View File
@@ -139,7 +139,7 @@
<section id="one-line">
<h2>One-line install</h2>
<p>If you prefer a curl pipe, use the repository installer:</p>
<p>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.</p>
<div class="code-card">
<div class="copy-row">
<strong>One-liner</strong>
+5 -5
View File
@@ -297,11 +297,11 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
"version": 1,
"tasks": [
{
"id": "docker.start",
"title": "Start Docker",
"group": "Docker",
"description": "Start the local Docker Compose environment.",
"command": "./docker/scripts/docker-compose-dev up -d"
"id": "app.start",
"title": "Start app",
"group": "Development",
"description": "Start the local development server.",
"command": "npm run dev"
},
{
"id": "db.reset",
+7 -7
View File
@@ -237,11 +237,11 @@ Configure workspace tasks in `.pi-web/tasks.json`:
"version": 1,
"tasks": [
{
"id": "docker.start",
"title": "Start Docker",
"group": "Docker",
"description": "Start the local Docker Compose environment.",
"command": "./docker/scripts/docker-compose-dev up -d"
"id": "app.start",
"title": "Start app",
"group": "Development",
"description": "Start the local development server.",
"command": "npm run dev"
},
{
"id": "db.reset",
@@ -760,7 +760,7 @@ Labels should use the same helper through a plugin-owned cache because `items()`
const envCache = new Map();
function envKey(machine, workspace) {
return `${machine.id}:${workspace.id}:docker/development.be-go.local.env`;
return `${machine.id}:${workspace.id}:.env.local`;
}
function loadEnvLabel(context) {
@@ -770,7 +770,7 @@ function loadEnvLabel(context) {
const pending = { status: "loading", label: undefined };
envCache.set(key, pending);
context.files.readFile("docker/development.be-go.local.env")
context.files.readFile(".env.local")
.then((file) => {
pending.status = "ready";
pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1];
+14 -3
View File
@@ -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`
<section class="toolbar"><strong>Updates</strong></section>
@@ -157,7 +168,7 @@ const plugin: PiWebPlugin = {
</svg>
`,
order: 100,
visible: (context) => shouldShowUpdatesPanel(context.state),
visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint),
badge: (context) => {
const count = messageCount(context.state);
return count > 0 ? count : undefined;
+82 -2
View File
@@ -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> = {}): PiWebComponentStatus {
return {
@@ -80,6 +80,20 @@ describe("recommendedCommand", () => {
expect(recommendedCommand(status({ commands: { restart: "pi-web restart" } }))).toBeUndefined();
});
it("preserves explicit Docker command text", () => {
expect(recommendedCommand(status({
release: { packageName: "@jmfederico/pi-web", updateAvailable: true },
commands: { update: "pi-web-docker update", restart: "pi-web-docker restart" },
}))).toEqual({ label: "Update & restart everything", command: "pi-web-docker update" });
expect(recommendedCommand(status({
components: {
web: component({ stale: true, installation: { kind: "docker", dockerMode: "dev" } }),
sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "docker", dockerMode: "dev" } }),
},
commands: { restart: "pi-web-docker --dev restart" },
}))).toEqual({ label: "Restart everything", command: "pi-web-docker --dev restart" });
});
it("does not fabricate a restart command when one is not configured", () => {
const result = recommendedCommand(status({
components: {
@@ -118,6 +132,39 @@ describe("additionalCommands", () => {
{ label: "Status", command: "pi-web status" },
]);
});
it("presents Docker runtime and development commands exactly as reported", () => {
expect(additionalCommands(status({
commands: {
update: "pi-web-docker update",
restart: "pi-web-docker restart",
restartWeb: "pi-web-docker restart-web",
restartSessiond: "pi-web-docker restart-sessiond",
status: "pi-web-docker status",
},
}), undefined)).toEqual([
{ label: "Update", command: "pi-web-docker update" },
{ label: "Restart all", command: "pi-web-docker restart" },
{ label: "Restart Web/UI", command: "pi-web-docker restart-web" },
{ label: "Restart session daemon", command: "pi-web-docker restart-sessiond" },
{ label: "Status", command: "pi-web-docker status" },
]);
expect(additionalCommands(status({
commands: {
update: "pi-web-docker --dev update",
restart: "pi-web-docker --dev restart",
restartWeb: "pi-web-docker --dev restart-web",
restartSessiond: "pi-web-docker --dev restart-sessiond",
status: "pi-web-docker --dev status",
},
}), { label: "Update & restart everything", command: "pi-web-docker --dev update" })).toEqual([
{ label: "Restart all", command: "pi-web-docker --dev restart" },
{ label: "Restart Web/UI", command: "pi-web-docker --dev restart-web" },
{ label: "Restart session daemon", command: "pi-web-docker --dev restart-sessiond" },
{ label: "Status", command: "pi-web-docker --dev status" },
]);
});
});
describe("shouldShowUpdatesPanel", () => {
@@ -134,12 +181,17 @@ 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);
});
it("shows the panel for local or unknown installs", () => {
it("shows the panel for local, Docker, or unknown installs", () => {
const local = status({
components: {
web: component({ installation: { kind: "local" } }),
@@ -148,6 +200,14 @@ describe("shouldShowUpdatesPanel", () => {
});
expect(shouldShowUpdatesPanel(stateWith(local))).toBe(true);
const docker = status({
components: {
web: component({ installation: { kind: "docker", dockerMode: "runtime" } }),
sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "docker", dockerMode: "runtime" } }),
},
});
expect(shouldShowUpdatesPanel(stateWith(docker))).toBe(true);
const unknown = status({
components: {
web: component({ installation: { kind: "pi-package" } }),
@@ -168,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);
@@ -190,6 +268,8 @@ describe("installationLabel", () => {
expect(installationLabel({ kind: "unknown" })).toBe("installation unknown");
expect(installationLabel({ kind: "npm-global" })).toBe("global npm package");
expect(installationLabel({ kind: "local" })).toBe("local checkout");
expect(installationLabel({ kind: "docker", dockerMode: "runtime" })).toBe("Docker runtime");
expect(installationLabel({ kind: "docker", dockerMode: "dev" })).toBe("Docker development runtime");
});
it("includes source and scope for pi-package installs", () => {
+40 -6
View File
@@ -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.
@@ -45,16 +49,45 @@ export function messageCount(state: PluginRuntimeState | undefined): number {
return messagesFor(state).length;
}
export function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean {
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
export function isSelfManagedInstallation(installation: PiWebInstallationInfo | undefined): boolean {
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 isLocalOrUnknownInstallation(status.components.web.installation)
|| isLocalOrUnknownInstallation(status.components.sessiond.installation);
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 {
@@ -70,5 +103,6 @@ export function installationLabel(installation: PiWebInstallationInfo | undefine
}
if (installation.kind === "npm-global") return "global npm package";
if (installation.kind === "local") return "local checkout";
if (installation.kind === "docker") return installation.dockerMode === "dev" ? "Docker development runtime" : "Docker runtime";
return "installation unknown";
}
@@ -28,7 +28,7 @@ describe("workspace tasks config", () => {
title: "Start Docker",
description: "Start the dev stack.",
group: "Docker",
command: "./docker/scripts/docker-compose-dev up -d",
command: "./docker/pi-web-docker --dev start",
confirm: true,
},
],
@@ -42,7 +42,7 @@ describe("workspace tasks config", () => {
title: "Start Docker",
description: "Start the dev stack.",
group: "Docker",
command: "./docker/scripts/docker-compose-dev up -d",
command: "./docker/pi-web-docker --dev start",
confirm: true,
},
],
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+28 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -53,6 +53,33 @@ describe("API parsers", () => {
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
});
it("parses Docker PI WEB installation metadata", () => {
const response = {
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", dockerMode: "dev" } },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { restart: "pi-web-docker restart", status: "pi-web-docker status" },
messages: [],
};
const parsed = parsePiWebStatusResponse(response);
expect(parsed.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
expect(parsed.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "dev" });
expect(parsed.commands).toEqual({ restart: "pi-web-docker restart", status: "pi-web-docker status" });
expect(() => parsePiWebStatusResponse({
...response,
components: {
...response.components,
web: { ...response.components.web, installation: { kind: "docker", dockerMode: "hidden" } },
},
})).toThrow("Invalid PI WEB Docker mode");
});
it("parses PI WEB plugin status responses", () => {
expect(parsePiWebPluginsResponse({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
+4 -1
View File
@@ -761,15 +761,18 @@ function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo |
if (value === undefined) return undefined;
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
const scope = record["scope"];
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid PI WEB installation scope");
const dockerMode = record["dockerMode"];
if (dockerMode !== undefined && dockerMode !== "runtime" && dockerMode !== "dev") throw new Error("Invalid PI WEB Docker mode");
return {
kind,
...optionalField("path", optionalString(record, "path")),
...optionalField("source", optionalString(record, "source")),
...(scope === undefined ? {} : { scope }),
...optionalField("npmRoot", optionalString(record, "npmRoot")),
...(dockerMode === undefined ? {} : { dockerMode }),
};
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import {
PI_WEB_DOCKER_USER_COMMANDS,
parsePiWebDockerArgs,
piWebDockerCommand,
piWebDockerCommandPrefix,
planPiWebDockerDevHostCommand,
planPiWebDockerRuntimeHostCommand,
validatePiWebDockerDevRootSafety,
} from "./piWebDockerCommandPlan.js";
describe("pi-web-docker command planning", () => {
it("plans runtime commands by default", () => {
expect(parsePiWebDockerArgs(["status"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "status", allowRoot: false, args: [] },
});
});
it("emits runtime commands by default and development commands explicitly", () => {
expect(parsePiWebDockerArgs(["--dev", "restart-sessiond"])).toEqual({
ok: true,
plan: { mode: "dev", command: "restart-sessiond", allowRoot: false, args: [] },
});
expect(piWebDockerCommandPrefix(undefined)).toBe("pi-web-docker");
expect(piWebDockerCommandPrefix("runtime")).toBe("pi-web-docker");
expect(piWebDockerCommandPrefix("dev")).toBe("pi-web-docker --dev");
expect(piWebDockerCommand(undefined, "status")).toBe("pi-web-docker status");
expect(piWebDockerCommand("runtime", "update")).toBe("pi-web-docker update");
expect(piWebDockerCommand("dev", "status")).toBe("pi-web-docker --dev status");
});
it("keeps production install out of development mode", () => {
expect(parsePiWebDockerArgs(["install", "--install-dir", "/srv/pi-web-docker"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "install", allowRoot: false, args: ["--install-dir", "/srv/pi-web-docker"] },
});
expect(parsePiWebDockerArgs(["--dev", "install"])).toEqual({
ok: false,
errors: ["install is only available in runtime mode"],
});
});
it("validates logs and shell targets", () => {
expect(parsePiWebDockerArgs(["--dev", "logs", "data-init"])).toEqual({
ok: true,
plan: { mode: "dev", command: "logs", allowRoot: false, args: ["data-init"], target: "data-init" },
});
expect(parsePiWebDockerArgs(["logs", "data-init"])).toEqual({
ok: false,
errors: ["logs data-init is only available in development mode"],
});
expect(parsePiWebDockerArgs(["shell"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "shell", allowRoot: false, args: [], target: "web" },
});
expect(parsePiWebDockerArgs(["shell", "data-init"])).toEqual({
ok: false,
errors: ["Invalid shell target: data-init"],
});
});
it("treats cli as the pi-web escape hatch", () => {
expect(parsePiWebDockerArgs(["cli", "config", "show"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "cli", allowRoot: false, args: ["config", "show"] },
});
expect(parsePiWebDockerArgs(["cli"])).toEqual({ ok: false, errors: ["cli requires pi-web arguments"] });
});
it("keeps the canonical user command surface parseable", () => {
const sampleArgs = new Map<string, string[]>([
["install", ["--asset-ref", "release"]],
["logs", ["web"]],
["shell", ["sessiond"]],
["cli", ["config", "show"]],
]);
for (const command of PI_WEB_DOCKER_USER_COMMANDS) {
const parsed = parsePiWebDockerArgs([command, ...(sampleArgs.get(command) ?? [])]);
expect(parsed).toMatchObject({ ok: true });
}
});
it("rejects unknown options and unexpected positional arguments", () => {
expect(parsePiWebDockerArgs(["--prod", "status"])).toEqual({ ok: false, errors: ["Unknown global option: --prod"] });
expect(parsePiWebDockerArgs(["status", "web"])).toEqual({ ok: false, errors: ["status does not accept positional arguments"] });
expect(parsePiWebDockerArgs(["restart-sessiond", "web"])).toEqual({ ok: false, errors: ["restart-sessiond does not accept positional arguments"] });
expect(parsePiWebDockerArgs([])).toEqual({ ok: false, errors: ["Missing command"] });
});
it("parses root override as an explicit global option", () => {
expect(parsePiWebDockerArgs(["--dev", "--allow-root", "status"])).toEqual({
ok: true,
plan: { mode: "dev", command: "status", allowRoot: true, args: [] },
});
});
it("plans production host commands through installer or Compose actions", () => {
expect(runtimeHostPlan(["install", "--asset-ref", "release"])).toEqual({
kind: "installer",
action: "install",
args: ["--asset-ref", "release"],
useRuntimeRootAsInstallDir: false,
});
expect(runtimeHostPlan(["update"])).toEqual({ kind: "installer", action: "update", args: [], useRuntimeRootAsInstallDir: true });
expect(runtimeHostPlan(["start"])).toEqual({ kind: "compose", args: ["up", "-d"] });
expect(runtimeHostPlan(["stop"])).toEqual({ kind: "compose", args: ["down"] });
expect(runtimeHostPlan(["restart"])).toEqual({ kind: "compose", args: ["restart", "web", "sessiond"] });
expect(runtimeHostPlan(["status"])).toEqual({ kind: "compose", args: ["ps"] });
expect(runtimeHostPlan(["logs", "web"])).toEqual({ kind: "compose", args: ["logs", "-f", "web"] });
expect(runtimeHostPlan(["shell"])).toEqual({ kind: "compose", args: ["exec", "web", "bash"] });
expect(runtimeHostPlan(["cli", "config", "show"])).toEqual({ kind: "compose", args: ["exec", "web", "pi-web", "config", "show"] });
});
it("plans development host commands through the generated dev Compose environment", () => {
expect(devHostPlan(["--dev", "start"])).toEqual({ kind: "compose", args: ["up", "-d", "--build"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "status"])).toEqual({ kind: "compose", args: ["ps"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "logs", "data-init"])).toEqual({ kind: "compose", args: ["logs", "-f", "data-init"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "shell"])).toEqual({ kind: "compose", args: ["exec", "web", "bash"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "cli", "config", "show"])).toEqual({ kind: "compose", args: ["exec", "web", "pi-web", "config", "show"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "update"])).toEqual({
kind: "composeSequence",
usesGeneratedEnv: true,
steps: [
{ args: ["build", "--pull"] },
{ args: ["up", "-d", "--force-recreate", "--remove-orphans"] },
],
});
});
it("keeps development root safety explicit in command planning", () => {
const parsed = parsePiWebDockerArgs(["--dev", "status"]);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
expect(validatePiWebDockerDevRootSafety(parsed.plan, 0)).toBe("refusing to run Docker development mode as root; retry with --allow-root if this is intentional");
expect(validatePiWebDockerDevRootSafety({ ...parsed.plan, allowRoot: true }, 0)).toBeUndefined();
expect(validatePiWebDockerDevRootSafety(parsed.plan, 500)).toBeUndefined();
});
it("does not apply production host planning to development mode", () => {
const parsed = parsePiWebDockerArgs(["--dev", "status"]);
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(planPiWebDockerRuntimeHostCommand(parsed.plan)).toBeUndefined();
});
it("does not apply development host planning to production mode", () => {
const parsed = parsePiWebDockerArgs(["status"]);
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(planPiWebDockerDevHostCommand(parsed.plan)).toBeUndefined();
});
});
function runtimeHostPlan(argv: string[]) {
const parsed = parsePiWebDockerArgs(argv);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
return planPiWebDockerRuntimeHostCommand(parsed.plan);
}
function devHostPlan(argv: string[]) {
const parsed = parsePiWebDockerArgs(argv);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
return planPiWebDockerDevHostCommand(parsed.plan);
}
+274
View File
@@ -0,0 +1,274 @@
export type PiWebDockerMode = "runtime" | "dev";
export type PiWebDockerCommand =
| "install"
| "start"
| "stop"
| "restart"
| "restart-web"
| "restart-sessiond"
| "update"
| "status"
| "logs"
| "shell"
| "doctor"
| "cli"
| "help";
export type PiWebDockerLogsTarget = "web" | "sessiond" | "data-init";
export type PiWebDockerShellTarget = "web" | "sessiond";
export interface PiWebDockerCommandPlan {
mode: PiWebDockerMode;
command: PiWebDockerCommand;
allowRoot: boolean;
args: string[];
target?: PiWebDockerLogsTarget | PiWebDockerShellTarget;
}
export type PiWebDockerParseResult =
| { ok: true; plan: PiWebDockerCommandPlan }
| { ok: false; errors: string[] };
export const PI_WEB_DOCKER_USER_COMMANDS = [
"install",
"start",
"stop",
"restart",
"restart-web",
"restart-sessiond",
"update",
"status",
"logs",
"shell",
"doctor",
"cli",
] as const satisfies readonly Exclude<PiWebDockerCommand, "help">[];
export type PiWebDockerRuntimeHostPlan =
| { kind: "installer"; action: "install" | "update"; args: string[]; useRuntimeRootAsInstallDir: boolean }
| { kind: "compose"; args: string[] }
| { kind: "diagnostics" }
| { kind: "usage" };
export interface PiWebDockerComposeStep {
args: string[];
}
export type PiWebDockerDevHostPlan =
| { kind: "compose"; args: string[]; usesGeneratedEnv: true }
| { kind: "composeSequence"; steps: PiWebDockerComposeStep[]; usesGeneratedEnv: true }
| { kind: "diagnostics"; usesGeneratedEnv: true }
| { kind: "usage" };
const noArgumentCommands = new Set<PiWebDockerCommand>([
"start",
"stop",
"restart",
"restart-web",
"restart-sessiond",
"update",
"status",
"doctor",
"help",
]);
const commands: ReadonlySet<string> = new Set([...PI_WEB_DOCKER_USER_COMMANDS, "help"]);
const logsTargets: ReadonlySet<string> = new Set(["web", "sessiond", "data-init"]);
const shellTargets: ReadonlySet<string> = new Set(["web", "sessiond"]);
export function piWebDockerCommandPrefix(mode: PiWebDockerMode | undefined): string {
return mode === "dev" ? "pi-web-docker --dev" : "pi-web-docker";
}
export function piWebDockerCommand(mode: PiWebDockerMode | undefined, command: Exclude<PiWebDockerCommand, "help">): string {
return `${piWebDockerCommandPrefix(mode)} ${command}`;
}
export function planPiWebDockerRuntimeHostCommand(plan: PiWebDockerCommandPlan): PiWebDockerRuntimeHostPlan | undefined {
if (plan.mode !== "runtime") return undefined;
switch (plan.command) {
case "install":
return { kind: "installer", action: "install", args: [...plan.args], useRuntimeRootAsInstallDir: false };
case "update":
return { kind: "installer", action: "update", args: [], useRuntimeRootAsInstallDir: true };
case "start":
return composeHostPlan("up", "-d");
case "stop":
return composeHostPlan("down");
case "restart":
return composeHostPlan("restart", "web", "sessiond");
case "restart-web":
return composeHostPlan("restart", "web");
case "restart-sessiond":
return composeHostPlan("restart", "sessiond");
case "status":
return composeHostPlan("ps");
case "logs":
return plan.target === undefined ? composeHostPlan("logs", "-f") : composeHostPlan("logs", "-f", plan.target);
case "shell":
return composeHostPlan("exec", plan.target ?? "web", "bash");
case "cli":
return { kind: "compose", args: ["exec", "web", "pi-web", ...plan.args] };
case "doctor":
return { kind: "diagnostics" };
case "help":
return { kind: "usage" };
}
}
export function planPiWebDockerDevHostCommand(plan: PiWebDockerCommandPlan): PiWebDockerDevHostPlan | undefined {
if (plan.mode !== "dev") return undefined;
switch (plan.command) {
case "install":
return undefined;
case "start":
return devComposeHostPlan("up", "-d", "--build");
case "stop":
return devComposeHostPlan("down");
case "restart":
return devComposeHostPlan("restart", "web", "sessiond");
case "restart-web":
return devComposeHostPlan("restart", "web");
case "restart-sessiond":
return devComposeHostPlan("restart", "sessiond");
case "update":
return {
kind: "composeSequence",
usesGeneratedEnv: true,
steps: [
{ args: ["build", "--pull"] },
{ args: ["up", "-d", "--force-recreate", "--remove-orphans"] },
],
};
case "status":
return devComposeHostPlan("ps");
case "logs":
return plan.target === undefined ? devComposeHostPlan("logs", "-f") : devComposeHostPlan("logs", "-f", plan.target);
case "shell":
return devComposeHostPlan("exec", plan.target ?? "web", "bash");
case "cli":
return { kind: "compose", args: ["exec", "web", "pi-web", ...plan.args], usesGeneratedEnv: true };
case "doctor":
return { kind: "diagnostics", usesGeneratedEnv: true };
case "help":
return { kind: "usage" };
}
}
export function validatePiWebDockerDevRootSafety(plan: PiWebDockerCommandPlan, uid: number): string | undefined {
if (plan.mode !== "dev" || plan.allowRoot || plan.command === "help" || uid !== 0) return undefined;
return "refusing to run Docker development mode as root; retry with --allow-root if this is intentional";
}
export function parsePiWebDockerArgs(argv: readonly string[]): PiWebDockerParseResult {
let mode: PiWebDockerMode = "runtime";
let allowRoot = false;
let index = 0;
for (; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === undefined) break;
if (arg === "--") {
index += 1;
break;
}
if (arg === "--dev") {
mode = "dev";
continue;
}
if (arg === "--allow-root") {
allowRoot = true;
continue;
}
if (arg === "--help" || arg === "-h") {
return { ok: true, plan: { mode, command: "help", allowRoot, args: [] } };
}
if (arg.startsWith("-")) {
return { ok: false, errors: [`Unknown global option: ${arg}`] };
}
break;
}
const commandValue = argv[index];
if (commandValue === undefined) return { ok: false, errors: ["Missing command"] };
if (!isPiWebDockerCommand(commandValue)) return { ok: false, errors: [`Unknown command: ${commandValue}`] };
const args = argv.slice(index + 1);
const plan: PiWebDockerCommandPlan = { mode, command: commandValue, allowRoot, args };
return validatePlan(withDefaultTarget(plan));
}
function composeHostPlan(...args: string[]): PiWebDockerRuntimeHostPlan {
return { kind: "compose", args };
}
function devComposeHostPlan(...args: string[]): PiWebDockerDevHostPlan {
return { kind: "compose", args, usesGeneratedEnv: true };
}
function withDefaultTarget(plan: PiWebDockerCommandPlan): PiWebDockerCommandPlan {
if (plan.command === "shell" && plan.target === undefined && plan.args.length === 0) return { ...plan, target: "web" };
return plan;
}
function validatePlan(plan: PiWebDockerCommandPlan): PiWebDockerParseResult {
const errors: string[] = [];
if (plan.command === "install" && plan.mode === "dev") {
errors.push("install is only available in runtime mode");
}
if (noArgumentCommands.has(plan.command) && plan.args.length > 0) {
errors.push(`${plan.command} does not accept positional arguments`);
}
if (plan.command === "logs") {
validateOptionalTarget(plan.args, isLogsTarget, "logs", errors);
if (plan.args[0] === "data-init" && plan.mode !== "dev") errors.push("logs data-init is only available in development mode");
}
if (plan.command === "shell") {
validateOptionalTarget(plan.args, isShellTarget, "shell", errors);
}
if (plan.command === "cli" && plan.args.length === 0) {
errors.push("cli requires pi-web arguments");
}
if (errors.length > 0) return { ok: false, errors };
const target = targetFrom(plan.command, plan.args);
return { ok: true, plan: target === undefined ? plan : { ...plan, target } };
}
function validateOptionalTarget(args: readonly string[], isAllowed: (value: string) => boolean, command: string, errors: string[]): void {
if (args.length > 1) {
errors.push(`${command} accepts at most one target`);
return;
}
const [target] = args;
if (target !== undefined && !isAllowed(target)) errors.push(`Invalid ${command} target: ${target}`);
}
function targetFrom(command: PiWebDockerCommand, args: readonly string[]): PiWebDockerCommandPlan["target"] | undefined {
const [target] = args;
if (target === undefined) return command === "shell" ? "web" : undefined;
if (command === "logs" && isLogsTarget(target)) return target;
if (command === "shell" && isShellTarget(target)) return target;
return undefined;
}
function isPiWebDockerCommand(value: string): value is PiWebDockerCommand {
return commands.has(value);
}
function isLogsTarget(value: string): value is PiWebDockerLogsTarget {
return logsTargets.has(value);
}
function isShellTarget(value: string): value is PiWebDockerShellTarget {
return shellTargets.has(value);
}
+54
View File
@@ -0,0 +1,54 @@
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { PI_WEB_DOCKER_USER_COMMANDS } from "./piWebDockerCommandPlan.js";
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", 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");
});
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 () => {
const [dockerReadme, dockerEntrypoint] = await Promise.all([
readRepoFile("docker/README.md"),
readRepoFile("docker/pi-web-docker"),
]);
for (const command of PI_WEB_DOCKER_USER_COMMANDS) {
expect(dockerReadme).toContain(`| \`${command}\` |`);
expect(dockerEntrypoint).toContain(command);
}
expect(dockerReadme).toContain("`pi-web-docker --dev status`");
expect(dockerReadme).toContain("`./docker/pi-web-docker --dev start`");
expect(dockerReadme).not.toContain("pi-web-docker-control");
expect(dockerReadme).not.toContain("docker/scripts/docker-compose-dev");
});
});
async function readRepoFile(relativePath: string): Promise<string> {
return await readFile(join(repoRoot, relativePath), "utf8");
}
+4
View File
@@ -223,6 +223,10 @@ function installationLabel(installation: PiWebInstallationInfo | undefined): str
return `global npm package${npmRoot}${path}`;
}
if (installation.kind === "local") return installation.path === undefined ? "local checkout" : `local checkout · ${installation.path}`;
if (installation.kind === "docker") {
const mode = installation.dockerMode === "dev" ? "Docker development runtime" : "Docker runtime";
return installation.path === undefined ? mode : `${mode} · ${installation.path}`;
}
return installation.path === undefined ? "installation unknown" : `installation unknown · ${installation.path}`;
}
+1
View File
@@ -8,6 +8,7 @@ export type {
FileTreeResponse,
MachineKind,
PiWebComponentStatus,
PiWebDockerMode,
PiWebInstallationInfo,
PiWebInstallationKind,
PiWebReleaseStatus,
+592
View File
@@ -0,0 +1,592 @@
import { execFile } from "node:child_process";
import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const dockerEntrypoint = join(repoRoot, "docker", "pi-web-docker");
let tempDir = "";
interface CommandResult {
stdout: string;
stderr: string;
}
interface FakeDocker {
binDir: string;
logPath: string;
}
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
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),
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
]);
});
it("packages the canonical Docker command and internal support assets", async () => {
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dockerignore] = await Promise.all([
readRepoFile("docker/Dockerfile"),
readRepoFile("docker/Dockerfile.dev"),
readRepoFile("docker/compose.yml"),
readRepoFile("docker/compose.dev.yml"),
readRepoFile("docker/install.sh"),
readRepoFile("docker/internal/dev/compose"),
readRepoFile("docker/.dockerignore"),
]);
expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
expect(dockerfile).toContain("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base");
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec");
expect(dockerignore).toContain("!pi-web-docker");
expect(dockerignore).toContain("!internal/bin/hostexec");
expect(installer).toContain("write_asset pi-web-docker 0755");
expect(installer).toContain("write_asset internal/host-profile.sh 0644");
expect(installer).toContain("compose_cmd --project-name \"$compose_project_name\"");
expect(installer).toContain("PI_WEB_DOCKER_INSTALL_DIR=$install_dir");
expect(installer).toContain("PI_WEB_DOCKER_REF=$asset_ref");
expect(installer).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
expect(devWrapper).toContain("$repo_root/docker/internal/host-profile.sh");
expect(devWrapper).toContain("--project-name \"$compose_project_name\"");
expect(devWrapper).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root");
expect(devWrapper).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_RUNTIME: \"1\"");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_MODE: runtime");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_INSTALL_DIR: ${PI_WEB_DOCKER_INSTALL_DIR:?set by docker/install.sh}");
expect(runtimeCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_IMAGE:-pi-web:local}");
expect(runtimeCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web}");
expect(devCompose).toContain("PI_WEB_DOCKER_MODE: dev");
expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}");
expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}");
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
});
dockerCommandIt("runs status through Docker Compose in the foreground", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
const result = await runDockerCommand(["status"], runtimeEnv(fakeDocker, installDir));
expect(result.stdout).toContain("fake docker compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose version");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
expect(log).not.toContain("run -d");
});
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);
await runDockerCommand(["start"], env);
await runDockerCommand(["stop"], env);
await runDockerCommand(["restart-sessiond"], env);
await runDockerCommand(["logs", "web"], env);
await runDockerCommand(["shell", "sessiond"], env);
await runDockerCommand(["cli", "config", "show"], env);
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml down");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml logs -f web");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec sessiond bash");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec web pi-web config show");
expect(log).not.toContain("run -d");
});
dockerCommandIt("ignores ambient Compose project names for runtime lifecycle commands", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
await runDockerCommand(["status"], {
...runtimeHostEnv(fakeDocker, installDir),
COMPOSE_PROJECT_NAME: "ambient-project",
});
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
expect(log).not.toContain("--project-name ambient-project");
});
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");
await installFakeId(fakeDocker.binDir, 1234, 2345);
const home = join(tempDir, "home");
const runtimeDataDir = join(tempDir, "runtime-data");
const runtimeEnvFile = join(tempDir, "runtime.env");
const socketPath = join(home, ".docker", "run", "docker.sock");
await writeFile(runtimeEnvFile, [
"PI_WEB_UID=0",
"PI_WEB_GID=0",
`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}`,
"PI_WEB_BIND_ADDR=0.0.0.0",
"COMPOSE_PROJECT_NAME=runtime-project",
"",
].join("\n"));
await withUnixSocket(socketPath, async () => {
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, { PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile }));
});
const generatedEnvPath = join(devRoot, ".pi-web", "docker-compose-dev.generated.env");
const generatedEnv = await readFile(generatedEnvPath, "utf8");
expect(generatedEnv).toContain("PI_WEB_UID=1234\n");
expect(generatedEnv).toContain("PI_WEB_GID=2345\n");
expect(generatedEnv).toContain("DOCKER_GID=0\n");
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}\n`);
expect(generatedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
expect(generatedEnv).toContain("PI_WEB_DEV_API_BIND_ADDR=0.0.0.0\n");
expect(generatedEnv).not.toContain("COMPOSE_PROJECT_NAME=runtime-project");
await withUnixSocket(socketPath, async () => {
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, {
COMPOSE_PROJECT_NAME: "ambient-dev-project",
PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile,
}));
});
const regeneratedEnv = await readFile(generatedEnvPath, "utf8");
expect(regeneratedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
expect(regeneratedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
expect(regeneratedEnv).not.toContain("COMPOSE_PROJECT_NAME=ambient-dev-project");
const localConfig = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.local.env"), "utf8");
expect(localConfig).toContain("docker/pi-web-docker --dev creates this file once");
expect(localConfig).toContain("PI_WEB_UID and PI_WEB_GID default to the current host user");
const override = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.host.generated.yml"), "utf8");
expect(override).toContain(socketPath);
expect(override).toContain(devRoot);
const log = await readFile(fakeDocker.logPath, "utf8");
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`);
});
dockerCommandIt("rejects development commands as root unless explicitly allowed", async () => {
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 0, 0);
const result = await runDockerCommandAllowFailure(["--dev", "status"], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("refusing to run Docker development mode as root");
});
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");
const devRoot = await createDevRepoFixtureWithFakeHelper(helperLog);
await runDockerCommand(["--dev", "--allow-root", "status"], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
});
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
});
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);
await runDockerCommand(["--dev", "restart-sessiond"], devRuntimeEnv(fakeDocker, devRoot));
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain(`--env-file ${devRoot}/.pi-web/docker-compose-dev.generated.env`);
expect(log).toContain("--group-add 3456");
expect(log).toContain("--user 1234:2345");
expect(log).toContain("PI_WEB_DOCKER_MODE=dev");
expect(log).toContain("PI_WEB_DOCKER_ALLOW_ROOT=0");
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
expect(log).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`);
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-dev-test");
expect(log).toContain("pi-web.docker-helper.mode=dev");
expect(log).toContain("pi-web:test pi-web-docker --dev __run-detached restart-sessiond");
expect(log).not.toContain("--user 0:0");
});
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",
PI_WEB_DOCKER_MODE: "dev",
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("this PI WEB Docker container is in dev mode");
});
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]");
});
dockerCommandIt("explains source checkout runtime-mode mistakes", async () => {
const fakeDocker = await installFakeDocker();
const result = await runDockerCommandAllowFailure(["start"], {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
HOME: "/home/pi-web-test",
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("runtime install assets were not found");
expect(result.stderr).toContain("running this checkout's Docker command in runtime mode");
expect(result.stderr).toContain("./docker/pi-web-docker --dev start");
expect(result.stderr).toContain("/home/pi-web-test/.local/share/pi-web-docker/pi-web-docker start");
expect(result.stderr).toContain("PI_WEB_DOCKER_INSTALL_DIR");
});
dockerCommandIt("starts restart-sessiond in a detached Docker helper", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
const result = await runDockerCommand(["restart-sessiond"], runtimeEnv(fakeDocker, installDir));
expect(result.stdout).toContain("Started detached PI WEB Docker helper");
expect(result.stdout).toContain("Follow progress with: docker logs -f pi-web-docker-restart-sessiond-");
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("container inspect");
expect(log).toContain("run -d");
expect(log).toContain("--env-file");
expect(log).toContain(`${installDir}/.env`);
expect(log).toContain("--volumes-from");
expect(log).toContain("--group-add 3456");
expect(log).toContain("--user 1234:2345");
expect(log).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`);
expect(log).toContain(`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`);
expect(log).toContain("PI_WEB_PORT=12345");
expect(log).toContain("PI_WEB_DOCKER_EXTRA_HOST_PATHS=/srv/pi-web-extra /opt/pi-web-extra");
expect(log).toContain("PI_WEB_EXTRA_ZYPPER_PACKAGES=git-lfs jq");
expect(log).not.toContain('PI_WEB_EXTRA_ZYPPER_PACKAGES="git-lfs jq"');
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-test");
expect(log).toContain("pi-web.docker-helper.mode=runtime");
expect(log).toContain("pi-web.docker-helper.root=");
expect(log).toContain("pi-web.docker-helper.project=pi-web-test");
expect(log).toContain("pi-web:test pi-web-docker __run-detached restart-sessiond");
expect(log).not.toContain("--user 0:0");
expect(log).not.toContain("compose -f compose.yml -f compose.override.yml restart sessiond");
});
dockerCommandIt("executes the detached restart-sessiond action through Compose", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
await runDockerCommand(["__run-detached", "restart-sessiond"], runtimeEnv(fakeDocker, installDir));
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
expect(log).not.toContain("run -d");
});
dockerCommandIt("executes the detached runtime update action through Compose without nesting helpers", async () => {
const installDir = await createRuntimeInstall();
const fakeDocker = await installFakeDocker();
await runDockerCommand(["__run-detached", "update"], runtimeEnv(fakeDocker, installDir));
const log = await readFile(fakeDocker.logPath, "utf8");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml build --pull --no-cache");
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d --force-recreate --remove-orphans");
expect(log).not.toContain("run -d");
});
});
async function readRepoFile(relativePath: string): Promise<string> {
return await readFile(join(repoRoot, relativePath), "utf8");
}
function runDockerCommand(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
return execUtf8("sh", [dockerEntrypoint, ...args], env);
}
function runDockerCommandAllowFailure(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
return execUtf8AllowFailure("sh", [dockerEntrypoint, ...args], env);
}
function execUtf8(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
return new Promise((resolvePromise, reject) => {
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
if (error !== null) {
reject(error instanceof Error ? error : new Error("Process failed"));
return;
}
resolvePromise({ stdout, stderr });
});
});
}
function execUtf8AllowFailure(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
return new Promise((resolvePromise) => {
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
resolvePromise({ stdout, stderr, exitCode });
});
});
}
async function createRuntimeInstall(): Promise<string> {
const installDir = join(tempDir, "runtime");
await mkdir(installDir, { recursive: true });
await writeFile(join(installDir, ".env"), [
"PI_WEB_UID=1234",
"PI_WEB_GID=2345",
"DOCKER_GID=3456",
`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`,
`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`,
"PI_WEB_DOCKER_EXTRA_HOST_PATHS=\"/srv/pi-web-extra /opt/pi-web-extra\"",
"PI_WEB_BIND_ADDR=127.0.0.1",
"PI_WEB_PORT=12345",
"PI_WEB_EXTRA_ZYPPER_PACKAGES=\"git-lfs jq\"",
"PI_WEB_IMAGE=pi-web:test",
"COMPOSE_PROJECT_NAME=pi-web-test",
"",
].join("\n"), "utf8");
await writeFile(join(installDir, "compose.yml"), "name: pi-web\nservices: {}\n", "utf8");
await writeFile(join(installDir, "compose.override.yml"), "services: {}\n", "utf8");
return installDir;
}
async function createDevRepoFixture(): Promise<string> {
const devRoot = join(tempDir, "dev-repo");
await mkdir(join(devRoot, "docker", "internal", "dev"), { recursive: true });
await copyFile(join(repoRoot, "docker", "internal", "dev", "compose"), join(devRoot, "docker", "internal", "dev", "compose"));
await chmod(join(devRoot, "docker", "internal", "dev", "compose"), 0o755);
await copyFile(join(repoRoot, "docker", "internal", "host-profile.sh"), join(devRoot, "docker", "internal", "host-profile.sh"));
await writeFile(join(devRoot, "docker", "compose.dev.yml"), "name: pi-web-dev\nservices: {}\n", "utf8");
return devRoot;
}
async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<string> {
const devRoot = join(tempDir, "dev-repo-fake-helper");
const helperPath = join(devRoot, "docker", "internal", "dev", "compose");
await mkdir(dirname(helperPath), { recursive: true });
await writeFile(helperPath, `#!/usr/bin/env sh
set -eu
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >${shellSingleQuote(logPath)}
`, "utf8");
await chmod(helperPath, 0o755);
return devRoot;
}
async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
const devRoot = join(tempDir, "dev-runtime");
await mkdir(join(devRoot, ".pi-web"), { recursive: true });
await writeFile(join(devRoot, ".pi-web", "docker-compose-dev.generated.env"), [
`PI_WEB_UID=${String(ids.uid)}`,
`PI_WEB_GID=${String(ids.gid)}`,
`DOCKER_GID=${String(ids.dockerGid)}`,
`PI_WEB_DOCKER_DATA_DIR=${join(tempDir, "dev-data")}`,
`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`,
"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",
"PI_WEB_DEV_IMAGE=pi-web:test",
"COMPOSE_PROJECT_NAME=pi-web-dev-test",
"",
].join("\n"), "utf8");
return devRoot;
}
async function installFakeDocker(): Promise<FakeDocker> {
const binDir = join(tempDir, "bin");
const logPath = join(tempDir, "docker.log");
const dockerPath = join(binDir, "docker");
await mkdir(binDir, { recursive: true });
await writeFile(dockerPath, `#!/usr/bin/env sh
set -eu
: "\${FAKE_DOCKER_LOG:?}"
printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG"
case "\${1:-}" in
--version)
printf 'Docker version 99.0.0, fake\n'
exit 0
;;
context)
case "\${2:-}" in
show)
printf 'default\n'
exit 0
;;
inspect)
exit 0
;;
esac
;;
info)
if [ "\${2:-}" = --format ]; then
printf 'Docker Desktop\n'
else
printf 'Fake Docker info\n'
fi
exit 0
;;
compose)
if [ "\${2:-}" = version ]; then
exit 0
fi
printf 'fake docker'
for arg in "$@"; do
printf ' %s' "$arg"
done
printf '\n'
exit 0
;;
container)
if [ "\${2:-}" = inspect ]; then
for arg in "$@"; do
if [ "$arg" = --format ]; then
printf 'pi-web:test\n'
exit 0
fi
done
printf '{}\n'
exit 0
fi
;;
ps|rm)
exit 0
;;
run)
printf 'fake-helper-container-id\n'
exit 0
;;
esac
printf 'unexpected fake docker args: %s\n' "$*" >&2
exit 9
`, "utf8");
await chmod(dockerPath, 0o755);
return { binDir, logPath };
}
async function installFakeUname(binDir: string, osName: string): Promise<void> {
const unamePath = join(binDir, "uname");
await writeFile(unamePath, `#!/usr/bin/env sh
set -eu
printf '%s\n' ${shellSingleQuote(osName)}
`, "utf8");
await chmod(unamePath, 0o755);
}
async function installFakeId(binDir: string, uid: number, gid: number): Promise<void> {
const idPath = join(binDir, "id");
await writeFile(idPath, `#!/usr/bin/env sh
set -eu
case "\${1:-}" in
-u) printf '%s\n' ${String(uid)} ;;
-g) printf '%s\n' ${String(gid)} ;;
*) printf '%s\n' ${String(uid)} ;;
esac
`, "utf8");
await chmod(idPath, 0o755);
}
async function withUnixSocket<T>(socketPath: string, callback: () => Promise<T>): Promise<T> {
await mkdir(dirname(socketPath), { recursive: true });
const server = createServer();
await new Promise<void>((resolvePromise, reject) => {
server.once("error", reject);
server.listen(socketPath, resolvePromise);
});
try {
return await callback();
} finally {
await new Promise<void>((resolvePromise) => {
server.close(() => {
resolvePromise();
});
});
await rm(socketPath, { force: true });
}
}
function cleanProcessEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) {
Reflect.deleteProperty(env, key);
}
}
return env;
}
function runtimeEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
return {
...runtimeHostEnv(fakeDocker, installDir),
PI_WEB_DOCKER_RUNTIME: "1",
};
}
function runtimeHostEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
return {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_RUNTIME: "0",
PI_WEB_DOCKER_MODE: "runtime",
PI_WEB_DOCKER_INSTALL_DIR: installDir,
};
}
function devHostEnv(fakeDocker: FakeDocker, devRoot: string, home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
...cleanProcessEnv(),
...extra,
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
HOME: home,
DOCKER_HOST: "",
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_RUNTIME: "0",
PI_WEB_DOCKER_MODE: "dev",
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
};
}
function devRuntimeEnv(fakeDocker: FakeDocker, devRoot: string): NodeJS.ProcessEnv {
return {
...cleanProcessEnv(),
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
FAKE_DOCKER_LOG: fakeDocker.logPath,
PI_WEB_DOCKER_RUNTIME: "1",
PI_WEB_DOCKER_MODE: "dev",
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
PI_WEB_DOCKER_CONTAINER_ID: "current-container",
};
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
+28
View File
@@ -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, {
@@ -226,3 +249,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;
}
+30 -1
View File
@@ -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 [];
+90 -2
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -9,10 +9,20 @@ import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
const originalHome = process.env["HOME"];
const originalPath = process.env["PATH"];
const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
afterEach(() => {
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
restoreEnv("HOME", originalHome);
restoreEnv("PATH", originalPath);
restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
vi.restoreAllMocks();
});
@@ -61,6 +71,7 @@ describe("PI WEB status", () => {
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
@@ -82,9 +93,13 @@ describe("PI WEB status", () => {
it("suggests native systemd commands for local development services", async () => {
if (process.platform !== "linux") return;
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
const home = await tempHome();
const binDir = await tempHome();
try {
process.env["HOME"] = home;
await installExecutable(binDir, "systemctl");
process.env["PATH"] = `${binDir}:${process.env["PATH"] ?? ""}`;
await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
const daemon = daemonWithComponent(staleLocalSessiond());
@@ -95,12 +110,72 @@ describe("PI WEB status", () => {
expect(status.commands.restartSessiond).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
} finally {
await rm(home, { recursive: true, force: true });
await Promise.all([
rm(home, { recursive: true, force: true }),
rm(binDir, { recursive: true, force: true }),
]);
}
});
it("suggests Docker commands when running inside the Docker runtime", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
process.env["PI_WEB_DOCKER_INSTALL_DIR"] = "/srv/pi-web-docker";
process.env["PATH"] = "";
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } });
const status = await getPiWebStatus(daemon);
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
expect(status.commands).toEqual({
update: "pi-web-docker update",
restart: "pi-web-docker restart",
restartWeb: "pi-web-docker restart-web",
restartSessiond: "pi-web-docker restart-sessiond",
status: "pi-web-docker status",
});
expect(JSON.stringify(status)).not.toContain("npm install -g");
expect(JSON.stringify(status)).not.toContain("pi-web restart");
});
it("suggests explicit Docker development commands when running inside the Docker dev runtime", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "dev";
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
process.env["PATH"] = "";
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } });
const status = await getPiWebStatus(daemon);
expect(status.commands).toEqual({
update: "pi-web-docker --dev update",
restart: "pi-web-docker --dev restart",
restartWeb: "pi-web-docker --dev restart-web",
restartSessiond: "pi-web-docker --dev restart-sessiond",
status: "pi-web-docker --dev status",
});
});
it("infers explicit Docker development commands from the generated dev root when mode is omitted", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
process.env["PATH"] = "";
const daemon = daemonWithComponent(staleLocalSessiond());
const status = await getPiWebStatus(daemon);
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
expect(status.commands.update).toBe("pi-web-docker --dev update");
expect(status.commands.status).toBe("pi-web-docker --dev status");
});
it("omits local restart commands when no native service command is known", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
const home = await tempHome();
try {
process.env["HOME"] = home;
@@ -150,6 +225,19 @@ async function installSystemdServiceFiles(home: string, names: string[]): Promis
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
}
async function installExecutable(dir: string, name: string): Promise<void> {
const path = join(dir, name);
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
await chmod(path, 0o755);
}
function disableDockerRuntimeEnv(): void {
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_INSTALL_DIR");
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_DEV_REPO_ROOT");
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
+56
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
@@ -188,6 +189,8 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
}
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
const docker = detectDockerInstallation();
if (docker !== undefined) return docker;
const root = packageRootPath();
const realRoot = await realPathOrSelf(root);
const piPackage = await detectPiPackageInstallation(realRoot, root);
@@ -197,6 +200,46 @@ async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
return { kind: "local", path: root };
}
function detectDockerInstallation(): PiWebInstallationInfo | undefined {
if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
const dockerMode = dockerModeFromEnv(process.env["PI_WEB_DOCKER_MODE"]) ?? inferredDockerModeFromRoots();
const path = dockerRootPathFromEnv(dockerMode);
return {
kind: "docker",
...(path === undefined ? {} : { path }),
...(dockerMode === undefined ? {} : { dockerMode }),
};
}
function dockerModeFromEnv(value: string | undefined): PiWebInstallationInfo["dockerMode"] | undefined {
return value === "runtime" || value === "dev" ? value : undefined;
}
function inferredDockerModeFromRoots(): PiWebInstallationInfo["dockerMode"] | undefined {
if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
return undefined;
}
function dockerRootPathFromEnv(mode: PiWebInstallationInfo["dockerMode"] | undefined): string | undefined {
if (mode === "dev") return firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", "PI_WEB_DOCKER_INSTALL_DIR");
if (mode === "runtime") return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
}
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";
}
async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
try {
const agentDir = getAgentDir();
@@ -377,6 +420,8 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components);
if (installation?.kind === "docker") return dockerCommands(installation);
const [serviceCommands, cliCommands] = await Promise.all([
nativeServiceCommands(),
piWebCliCommands(installation),
@@ -399,10 +444,21 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi
function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
const web = components.web.installation;
const sessiond = components.sessiond.installation;
if (web?.kind === "docker" || sessiond?.kind === "docker") return web?.kind === "docker" ? web : sessiond;
if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
return web ?? sessiond;
}
function dockerCommands(installation: PiWebInstallationInfo): PiWebStatusResponse["commands"] {
return {
update: piWebDockerCommand(installation.dockerMode, "update"),
restart: piWebDockerCommand(installation.dockerMode, "restart"),
restartWeb: piWebDockerCommand(installation.dockerMode, "restart-web"),
restartSessiond: piWebDockerCommand(installation.dockerMode, "restart-sessiond"),
status: piWebDockerCommand(installation.dockerMode, "status"),
};
}
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
return { restart: "pi-web restart", status: "pi-web status" };
+3 -1
View File
@@ -557,7 +557,8 @@ export interface TerminalCommandRunFilter {
export type PiWebServiceComponent = "web" | "sessiond";
export type PiWebStatusSeverity = "info" | "warning" | "error";
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "unknown";
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "docker" | "unknown";
export type PiWebDockerMode = "runtime" | "dev";
export interface PiWebInstallationInfo {
kind: PiWebInstallationKind;
@@ -565,6 +566,7 @@ export interface PiWebInstallationInfo {
source?: string;
scope?: "user" | "project";
npmRoot?: string;
dockerMode?: PiWebDockerMode;
}
export interface PiWebComponentStatus {
+39 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "./capabilities";
import { parsePiWebRuntimeResponse } from "./piWebStatusParsing";
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing";
describe("PI WEB status parsing", () => {
it("parses known runtime capabilities and ignores unknown string capabilities", () => {
@@ -32,4 +32,42 @@ describe("PI WEB status parsing", () => {
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
})).toBeUndefined();
});
it("parses Docker installation metadata", () => {
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
kind: "docker",
path: "/srv/pi-web-docker",
dockerMode: "runtime",
});
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" })).toEqual({
kind: "docker",
path: "/workspace/pi-web",
dockerMode: "dev",
});
});
it("ignores invalid optional Docker modes without rejecting component status", () => {
expect(parsePiWebComponentStatus({
component: "web",
label: "Web/UI",
runtimeVersion: "1.0.0",
stale: false,
available: true,
installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "hidden" },
})?.installation).toEqual({ kind: "docker", path: "/workspace/pi-web" });
});
it("parses version responses that include Docker runtime and development components", () => {
const parsed = parsePiWebVersionResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true, installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } },
},
});
expect(parsed?.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
expect(parsed?.components.sessiond.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
});
});
+3 -1
View File
@@ -77,13 +77,15 @@ export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInf
const source = value["source"];
const scope = value["scope"];
const npmRoot = value["npmRoot"];
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
const dockerMode = value["dockerMode"];
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") return undefined;
return {
kind,
...(typeof path === "string" ? { path } : {}),
...(typeof source === "string" ? { source } : {}),
...(scope === "user" || scope === "project" ? { scope } : {}),
...(typeof npmRoot === "string" ? { npmRoot } : {}),
...(dockerMode === "runtime" || dockerMode === "dev" ? { dockerMode } : {}),
};
}