diff --git a/.agents/skills/code-quality-architecture/SKILL.md b/.agents/skills/code-quality-architecture/SKILL.md index 290e9b5..6be1e78 100644 --- a/.agents/skills/code-quality-architecture/SKILL.md +++ b/.agents/skills/code-quality-architecture/SKILL.md @@ -1,12 +1,14 @@ --- name: code-quality-architecture -description: Project code quality and architecture expectations for implementation, refactoring, planning, and code review. Use this skill whenever writing, modifying, reviewing, or planning code in this repository, especially when making architecture choices, introducing modules/services/components, managing side effects, dependencies, state, boundaries, or tests. Favor composable, contained, intention-revealing, separated, dependency-injected, testable code while respecting the idioms of the framework or library in use. +description: Project code quality and architecture expectations for implementation, refactoring, planning, and code review. Use this skill whenever writing, modifying, reviewing, or planning production code or architecture in this repository, especially when making architecture choices, introducing modules/services/components, managing side effects, dependencies, state, or boundaries. Favor composable, contained, intention-revealing, separated, dependency-injected, testable code while respecting the idioms of the framework or library in use. --- # Code quality and architecture expectations Use this skill as a design lens, not as a framework tutorial. The goal is to shape code so future agents and humans can understand it, change it safely, and test it without needing to reverse-engineer hidden coupling. +For test-specific strategy, test helper conventions, and UI test harness choices, use the `testing-guide` skill. This skill still treats testability as a production-code design concern. + Respect the project's existing conventions and the framework/library idioms already in use. If a dependency expects a particular pattern, such as inheritance, decorators, lifecycle hooks, or a registration API, use that pattern deliberately and keep the surrounding project code as simple and composable as possible. ## Values we optimize for diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md new file mode 100644 index 0000000..baaf5af --- /dev/null +++ b/.agents/skills/testing-guide/SKILL.md @@ -0,0 +1,87 @@ +--- +name: testing-guide +description: Repository-specific testing guide. Use for any test work: planning coverage, writing/fixing/reviewing Vitest tests, test helpers/fakes, failure triage, choosing test layers, and Lit UI tests, including TemplateResult handler extraction rules. +--- + +# Testing guide + +Use this skill for test-specific decisions in this repository. The goal is useful regression coverage without letting test helpers, mocks, or component harnesses become a second application that is harder to maintain than the code under test. + +For production-code design and testability seams, also use the `code-quality-architecture` skill. This guide owns test strategy, test helper conventions, and UI test escape hatches. + +## Core principles + +- Test behavior and contracts that matter, not branches for their own sake. +- Prefer the smallest layer that proves the behavior: pure helper, service, controller, route/API contract, component boundary, then broader integration. +- Keep tests deterministic. Fake clocks, browser globals, filesystem/process/network boundaries, and hard-to-trigger errors when needed. +- Assert observable outcomes: return values, state transitions, emitted calls/events, HTTP responses, rendered user-facing state, or durable side effects. +- Avoid asserting incidental implementation details unless the selected gap is specifically about that implementation contract. +- Keep setup readable. A small explicit fixture is better than a magical factory that hides the scenario. +- Clean up global stubs, fake timers, DOM state, and pending promises so tests do not leak into one another. + +## Choosing the test layer + +Prefer this order unless the behavior requires a higher layer: + +1. **Pure helper/service tests** for data shaping, validation, cache decisions, command construction, and conversion logic. +2. **Controller/runtime adapter tests** for state orchestration, endpoint selection, cancellation, timers, and injected collaborators. +3. **Route/API contract tests** for HTTP status mapping, path/query/body parsing, proxy allowlists, and compatibility contracts. +4. **Component-boundary tests** for UI event wiring and rendered state. Prefer real DOM/custom-element interaction when practical. +5. **Broad verification** (`npm run verify`) when a change is cross-cutting, changes shared helpers/types, or before final merge review. + +Do not jump to a broad UI or integration test just because it feels more realistic if a lower layer proves the same behavior with less noise and less flake risk. + +## Test helpers and fakes + +- Keep helpers local until reuse is clear. If a pattern appears in multiple files, consolidate deliberately rather than copy-pasting variants. +- Type helpers and fakes strictly; avoid `any` unless the test is intentionally modeling an untyped external boundary. +- Fake only the boundary needed for the scenario. Do not mock the unit under test or so many collaborators that the assertion stops proving real behavior. +- Prefer controllable promises, fake timers, and explicit injected dependencies over sleeps or timing guesses. +- Name helpers after the domain behavior they support, not the mechanics of the fake. + +## Lit component tests + +Prefer testing Lit components through public/component boundaries: + +- instantiate the component and set properties when that is the component contract; +- dispatch events against rendered DOM when a lightweight DOM harness is practical; +- assert user-visible rendered state or controller calls caused by user-like interactions. + +### TemplateResult event-handler extraction rule + +Lit `TemplateResult` event-handler extraction means calling `render()`, inspecting the returned template's `strings`/`values`, finding an event handler near a marker, and invoking that handler directly. It is an escape hatch, not the default. + +Use TemplateResult handler extraction only when all of these are true: + +1. The test is specifically verifying Lit template event wiring. +2. A DOM/custom-element render harness would add disproportionate setup, flakiness, or noise for the behavior being checked. +3. The assertion checks observable component/controller effects, not Lit internals. +4. The lookup is anchored to stable semantic markup, labels, or user-facing text rather than incidental handler order. +5. The test stays narrow; it is not trying to cover a full user flow, accessibility behavior, or visual/layout behavior. + +Do not use TemplateResult handler extraction for: + +- general content assertions; +- styling, layout, focus, keyboard navigation, or accessibility behavior; +- broad user flows where real DOM events are the point; +- scenarios with an existing public controller/service/helper seam; +- copying a new ad hoc helper variant into another file without reviewing whether a shared helper or DOM harness is now warranted. + +When using this escape hatch: + +- Add a short comment above the helper or test explaining why direct handler extraction is proportionate. +- Keep the helper small, type-guarded, and file-local unless reuse is already justified. +- Anchor searches to stable semantic markers such as accessible labels, button text, ids intentionally used by the component, or nearby form markup. +- Assert the behavior caused by the handler, such as state changes or calls to injected callbacks/controllers. +- Avoid assertions about the exact shape of Lit's private data beyond the minimum needed to find the handler; fail with clear errors if the template cannot be inspected. + +## Checks to run + +Run the narrowest meaningful check first: + +- Changed test file: `npm test -- --run `. +- Source or exported type changes: also run `npm run typecheck`. +- Non-trivial test helper, component, or lint-sensitive changes: run `npx eslint ` or `npm run lint` when broader lint coverage is needed. +- Cross-cutting changes or final merge review: prefer `npm run verify`. + +Record exact commands and results when working under relay/audit workflows or when handing work to another agent. diff --git a/.changeset/bidi-chat-text.md b/.changeset/bidi-chat-text.md deleted file mode 100644 index 4b3efb7..0000000 --- a/.changeset/bidi-chat-text.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Improve chat, prompt, and session text rendering for RTL and mixed-direction content. diff --git a/.changeset/chat-file-uploads.md b/.changeset/chat-file-uploads.md deleted file mode 100644 index d65244b..0000000 --- a/.changeset/chat-file-uploads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. diff --git a/.changeset/doctor-native-service-context.md b/.changeset/doctor-native-service-context.md new file mode 100644 index 0000000..8969167 --- /dev/null +++ b/.changeset/doctor-native-service-context.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Validate install and doctor service requirements in the real systemd or launchd manager context before changing native services, with plan-specific PATH guidance and safe probe cleanup. Thanks to @blain3white for the original report, reproduction, and root-cause analysis. diff --git a/.changeset/fix-fish-doctor-version-check.md b/.changeset/fix-fish-doctor-version-check.md deleted file mode 100644 index 8163c01..0000000 --- a/.changeset/fix-fish-doctor-version-check.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check -wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, -which fish parses as a command substitution in command position and rejects -(`command substitutions not allowed in command position`), producing a false -negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. diff --git a/.changeset/fresh-machine-update-checks.md b/.changeset/fresh-machine-update-checks.md new file mode 100644 index 0000000..3676170 --- /dev/null +++ b/.changeset/fresh-machine-update-checks.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a **Check for PI WEB Updates** action that bypasses cached release data and refreshes update status for the selected local or federated machine. diff --git a/.changeset/git-inline-diff-highlights.md b/.changeset/git-inline-diff-highlights.md deleted file mode 100644 index 26e3890..0000000 --- a/.changeset/git-inline-diff-highlights.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Highlight within-line changes in the Git diff viewer. diff --git a/.changeset/manual-session-cleanup.md b/.changeset/manual-session-cleanup.md deleted file mode 100644 index 1ae85b1..0000000 --- a/.changeset/manual-session-cleanup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. diff --git a/.changeset/manual-workspace-uploads.md b/.changeset/manual-workspace-uploads.md deleted file mode 100644 index 033fb63..0000000 --- a/.changeset/manual-workspace-uploads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md deleted file mode 100644 index f3978be..0000000 --- a/.changeset/mobile-enter-newline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). diff --git a/.changeset/persist-subsession-links.md b/.changeset/persist-subsession-links.md deleted file mode 100644 index 8199eaa..0000000 --- a/.changeset/persist-subsession-links.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md deleted file mode 100644 index 5a3b3f3..0000000 --- a/.changeset/plugin-api-completeness.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. diff --git a/.changeset/plugin-panel-prompt-context.md b/.changeset/plugin-panel-prompt-context.md deleted file mode 100644 index e7ee9c2..0000000 --- a/.changeset/plugin-panel-prompt-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. diff --git a/.changeset/portable-base-paths.md b/.changeset/portable-base-paths.md new file mode 100644 index 0000000..603070e --- /dev/null +++ b/.changeset/portable-base-paths.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Support root and nested reverse-proxy deployments with one published client, including scoped PWA assets, WebSockets, and local or federated plugins. diff --git a/.changeset/serve-plugin-svg-assets.md b/.changeset/serve-plugin-svg-assets.md new file mode 100644 index 0000000..a17dea4 --- /dev/null +++ b/.changeset/serve-plugin-svg-assets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Serve PI WEB plugin SVG assets with a browser-compatible content type and clarify module-relative asset packaging. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..db14171 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8cc1b15..39d4981 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh set -eu -echo "Running pre-commit checks: npm run verify" -npm run verify +echo "Running pre-commit checks: npm run verify:staged" +npm run verify:staged diff --git a/.gitignore b/.gitignore index 052ea5e..afb4be8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,9 @@ dist/ # Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/. /dev-plugins/ +# Local Docker image build hooks for development containers. +/docker/custom-image.d/* +!/docker/custom-image.d/.gitkeep + # Local runtime attachment uploads (created by the chat composer "save to folder" mode). .pi-web/ diff --git a/AGENTS.md b/AGENTS.md index c503ccf..ec8687c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,23 @@ If you make changes that affect `src/server/sessiond.ts`, session runtime owners Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path. +## Testing guidance + +Project-specific testing rules live in `.agents/skills/testing-guide/SKILL.md`. + +Use that skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, triaging test failures, or creating test helpers/harnesses. Keep detailed testing conventions there rather than growing this top-level orientation file. + +## Client application URL convention + +- Build PI WEB-owned browser paths as application-relative references without a leading slash, for example `api/...` and `pi-web-plugins/...`. +- Encode every dynamic path segment with `encodeURIComponent`; encode query values, using `URLSearchParams` for multi-field queries. +- Resolve each reference exactly once at the browser boundary: ordinary JSON HTTP paths go to `request()`, direct browser APIs receive URLs from helpers backed by `resolveAppUrl()`, and WebSockets use `resolveAppWebSocketUrl()`. +- Name helpers returning unresolved application references with a `Path` suffix and helpers returning browser-ready absolute values with a `Url` suffix. +- Plugin module references must go through `resolvePluginModuleUrl()`. Its leading-slash handling is the documented rolling-compatibility exception; do not introduce other leading-root app references. +- Pre-JavaScript HTML assets use Vite `%BASE_URL%`; PWA manifest references stay `./`-relative. External links, data URLs, and module-relative plugin assets are not application paths. +- To assess deviations, search production client code for raw `fetch`, `WebSocket`, `XMLHttpRequest`, URL-bearing DOM attributes, and leading `/api` or `/pi-web-plugins` literals. Every app-owned result must follow one of the boundaries above. +- Published nested deployments require a canonical trailing slash; the reverse proxy must redirect a slashless prefix before serving the app. + ## Configuration conventions - `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7617ac9..9c1fba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # @jmfederico/pi-web +## 1.202607.0 + +### Patch Changes + +- d165d69: Make archive and delete actions reliable for large multi-session selections. +- d6cfffd: Allow chat copy buttons to work from HTTP private-network addresses by falling back when the browser Clipboard API is unavailable. +- a660ba8: Keep delegation tools available in human-created and independently spawned sessions, remove them from tracked child sessions, and guide parents to wait for required children at join points without polling. +- 256db33: Keep npm release builds working across platforms and exclude internal test-support modules from published packages. +- 338faf4: Speed up chat loading, session resume, and long-conversation rendering while reducing browser response sizes. +- ad62853: Show complete file paths and commands in tool headers and expanded details, with horizontal scrolling for long tool targets and results. +- a874798: Make spawned and tracked subsessions inherit the dispatching session's current model instead of falling back to the last globally selected model. +- eb17276: Preserve archive and archived-session delete actions for older federated PI WEB machines that do not yet advertise session persistence or delete capabilities. +- 8ade238: Manage Pi packages from Settings on the selected local or federated PI WEB machine, with install, update, and removal flows that respect each machine's advertised capabilities. +- 2009e6a: Keep the chat prompt stable during streaming so mobile touch gestures, including iOS paste and edit callouts, are not interrupted. +- 7063c2c: Prevent iOS Safari from zooming into small text inputs across the web UI. +- 386c67e: Require Pi 0.80 or newer and use its stable streaming API for session-name generation. +- 32907bb: Support Pi's `max` thinking level and refresh shipped runtime dependencies. +- 10efb7f: Name Relay handoff sessions consistently from their relay name and leg number. +- 256db33: Improve file suggestions by waiting for all Git probes before deciding whether to scan the wider workspace. +- 0b17b9d: Promote the Updates tab to stable by removing its beta label while keeping update message counts visible. +- 64b2b32: Edit machine-scoped PI WEB settings on the selected machine—including session daemon tools, plugin enablement, path access, and upload defaults—while keeping gateway/browser-only settings local and disabling unsupported remote forms. +- d2e10cd: Show generated suffixes for unnamed sessions so multiple new empty chats are easier to distinguish. +- 889672f: Add `/reload` for PI WEB sessions so newly installed Pi package resources can be loaded without restarting the session daemon, with separate guidance for browser plugin reloads. +- 2665d1e: Open new chats immediately—including on mobile—queue sends until their backend sessions are ready, and keep concurrent starts and archive/delete/reload actions aligned with server persistence. +- b61a9c0: Standardize Settings panels so descriptions, notices, and controls render in a consistent order. +- abcf44b: Show complete message dates and model identifiers in a consistent label, wrapping expanded metadata without changing message-header height. +- 02f34c4: Add a terminal copy mode with a touch-selectable, color-preserving output snapshot and a Copy all action for mobile browsers. + +## 1.202606.7 + +### Patch Changes + +- b17faeb: Improve chat, prompt, and session text rendering for RTL and mixed-direction content. +- 7e812aa: Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. +- 47c9b66: Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check + wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, + which fish parses as a command substitution in command position and rejects + (`command substitutions not allowed in command position`), producing a false + negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. +- b14205e: Highlight within-line changes in the Git diff viewer. +- cb13af4: Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. +- e46d9ec: Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. +- 32ea809: Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). +- a99696b: Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. +- 27a3b2b: Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. +- 9980027: Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. + ## 1.202606.6 ### Patch Changes diff --git a/README.md b/README.md index 86c68c6..fbbc619 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,19 @@ Read more: [Remote-first development](https://pi-web.dev/remote-first) ## Machines and fleets -PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, and activity from trusted remote machines. +PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, activity, Pi package management, and selected-machine settings from trusted remote machines. + +When a remote machine is selected, Settings tabs label their target. Pi packages, PI WEB plugin enablement, session daemon toggles, external file access, and upload defaults target the selected machine. Gateway/server settings such as host, port, allowed hosts, registered machines/tokens, and keyboard shortcuts stay local to the gateway/browser. Read more: [Fleet and machines guide](https://pi-web.dev/machines) ## Plugins -PI WEB supports trusted local browser-side plugins that can add actions, workspace panels, and workspace metadata. +PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata. + +Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** to enable or disable discovered browser plugins on the selected machine. + +After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session on that machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins. Read more: [Plugin API](https://pi-web.dev/plugins) @@ -122,7 +128,7 @@ Project-local PI WEB config lives at: /.pi-web/config.json ``` -Common configuration includes host/port, path access, uploads, plugins, shortcuts, and session daemon options. +Common configuration includes host/port, path access, uploads, PI WEB plugin enablement, shortcuts, and session daemon options. In Settings, machine-affecting config targets the selected machine; gateway host/port/allowed-hosts, remote machine registration, tokens, and keyboard shortcuts stay local. Read more: [Configuration reference](https://pi-web.dev/config) diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..31e252e --- /dev/null +++ b/docker/.dockerignore @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f14fa02 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,87 @@ +# 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 CACHE_BUST=local + +RUN set -eux; \ + echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \ + npm install -g --omit=dev --include=peer --no-audit --no-fund "@jmfederico/pi-web@${PI_WEB_VERSION}"; \ + global_root="$(npm root -g)"; \ + global_prefix="$(npm prefix -g)"; \ + peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"; \ + global_pi_bin="${global_prefix}/bin/pi"; \ + if [ -x "${peer_pi_bin}" ]; then \ + ln -sf "${peer_pi_bin}" "${global_pi_bin}"; \ + elif [ ! -x "${global_pi_bin}" ]; then \ + echo "Could not find pi binary from @earendil-works/pi-coding-agent" >&2; \ + exit 1; \ + fi; \ + 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"] diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..0f989d0 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,84 @@ +# 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 +# Keep an immutable dependency seed outside /workspace, which is hidden by the +# checkout bind mount at runtime. A cached generation is added after custom +# image hooks so it identifies the final dependency tree. +RUN npm ci \ + && install -d -m 0755 /opt/pi-web-dev-dependencies \ + && cp package.json package-lock.json /opt/pi-web-dev-dependencies/ \ + && chmod -R a+rwX /workspace/node_modules \ + && mv /workspace/node_modules /opt/pi-web-dev-dependencies/node_modules \ + && ln -s /opt/pi-web-dev-dependencies/node_modules /workspace/node_modules \ + && ln -sf /opt/pi-web-dev-dependencies/node_modules/.bin/pi /usr/local/bin/pi \ + && npm cache clean --force \ + && chmod -R a+rwX /data \ + && chmod 0777 /workspace + +COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules + +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/ +# Image hooks use the temporary /workspace/node_modules symlink. Leave an empty +# directory afterward so Compose can mount and populate the dependency volume. +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; \ + test -L /workspace/node_modules; \ + rm /workspace/node_modules; \ + install -d -m 0777 /workspace/node_modules; \ + zypper clean --all; \ + rm -rf /var/cache/zypp/* \ +' +# Cache the generation with the completed seed. Changes to any preceding layer, +# including custom image hooks, rerun this step and refresh the named volume. +RUN node -e 'process.stdout.write(`${require("node:crypto").randomUUID()}\n`)' > /opt/pi-web-dev-dependencies/generation + +EXPOSE 8504 8505 + +ENTRYPOINT ["tini", "--"] +CMD ["npm", "run", "dev"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..72bdafc --- /dev/null +++ b/docker/README.md @@ -0,0 +1,362 @@ +# 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: `/data`, mounted at `/data`; +- browser URL: ; +- npm packages: latest `@jmfederico/pi-web`; Pi Coding Agent is resolved as PI WEB's npm peer dependency (newest compatible version) and the peer-provided `pi` binary is linked into the image. + +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. After scheduling the helper, the command streams that helper's logs inline and prints the `docker logs -f` command needed to reconnect. The helper still runs independently, 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 `. From a checkout, run `./docker/pi-web-docker --dev ` for development mode. Inside PI WEB Docker containers and in the Updates panel, the command name is `pi-web-docker`; development commands include the explicit `--dev` flag, for example `pi-web-docker --dev status`. + +| 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. Development updates require a clean Git checkout with no Git operation in progress. | +| `status` | `./pi-web-docker status` | `./docker/pi-web-docker --dev status` | Shows Docker Compose service status. | +| `logs` | `./pi-web-docker logs [web\|sessiond]` | `./docker/pi-web-docker --dev logs [web\|sessiond\|data-init]` | Follows logs; omitting a target follows all services. | +| `shell` | `./pi-web-docker shell [web\|sessiond]` | `./docker/pi-web-docker --dev shell [web\|sessiond]` | Opens Bash in `web` by default. | +| `doctor` | `./pi-web-docker doctor` | `./docker/pi-web-docker --dev doctor` | Prints static Docker command diagnostics and generated asset paths. | +| `cli` | `./pi-web-docker cli ` | `./docker/pi-web-docker --dev cli ` | Proxies the existing `pi-web` CLI in the `web` container. | + +Do not run `docker compose down -v` unless you intentionally want to remove Compose-managed volumes. The default persistent PI WEB data is a bind mount, but avoiding `-v` keeps the update/stop flow conservative. + +### 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 +``` + +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 Coding Agent resolves from PI WEB's npm peer dependency | +| `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 npm package selection 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`, `vim`, 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 + +Pi Coding Agent is resolved from PI WEB's npm peer dependency, and Docker links the peer-provided `pi` binary into `PATH`. Pin the PI WEB npm package when you want to stay on a specific PI WEB release: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \ + | sh -s -- --pi-web-version 1.202606.4 +``` + +You can also edit `.env` in the install directory: + +```dotenv +PI_WEB_VERSION=1.202606.4 +``` + +Then rerun the one-liner to rebuild/recreate with that pin. Use `PI_WEB_VERSION=latest` when you want the runtime to track the newest PI WEB release and the newest Pi package compatible with PI WEB's peer dependency range. + +To pin the Docker asset templates themselves, fetch the installer from a specific Git branch, tag, or commit and pass the same ref as the asset source: + +```bash +ref= +curl -fsSL "https://raw.githubusercontent.com/jmfederico/pi-web/$ref/docker/install.sh" \ + | sh -s -- --asset-ref "$ref" +``` + +## Localhost binding and remote access + +The runtime listens on `0.0.0.0:8504` inside the container but publishes it to `127.0.0.1:8504` on the host by default. + +For SSH access from your laptop: + +```bash +ssh -L 8504:127.0.0.1:8504 user@server +# open http://127.0.0.1:8504 locally +``` + +For a trusted VPN/private interface, bind to that private address: + +```bash +curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \ + | sh -s -- --bind-address 100.x.y.z --port 8504 +``` + +If you use a reverse proxy, keep the container bound to localhost or a private address and put authentication/TLS at the proxy. Avoid `--bind-address 0.0.0.0` unless another trusted layer restricts access. + +## `hostexec` examples + +`hostexec [--root] ` is the native Linux host command bridge provided by this Docker setup. It is enabled only for the `linux-native-docker` profile and intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. + +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 +``` + +Development `update` is intentionally fail-closed. Before starting a Docker helper or build, it requires this repository to be a clean Git checkout, including no staged, modified, or untracked files, and no merge, rebase, cherry-pick, revert, sequenced operation, or bisect in progress. It never stashes, removes, or rewrites developer work; resolve, commit, stash, or remove that work explicitly and rerun the update. This guard applies only to `update`: `start` and restart commands remain available for normal development against an intentionally dirty checkout. + +The Docker command rebuilds the current checkout; it does not merge branches or resolve source updates. Perform any Git integration separately, then run the guarded Docker update after the checkout is clean. + +You can run the dev stack in the background with: + +```bash +./docker/pi-web-docker --dev start +``` + +Open the Vite UI at . The dev API is published on . + +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, stream the helper's logs inline after it starts, and keep update/restart work running 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/`, `/srv/`, or `/opt/`. On Mac, prefer paths under `/Users//...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path. + +Development startup keeps the persistent `node_modules` volume synchronized with the dependency tree built into the dev image. When `package.json`, `package-lock.json`, the Node image, or another dependency-build input changes, `start` or `update` rebuilds the image and `data-init` refreshes the volume before `sessiond` starts. Manual volume removal is not required. + +If Compose is invoked directly without rebuilding after a manifest change, `data-init` stops with a mismatch message instead of starting against stale dependencies. Run `./docker/pi-web-docker --dev start` or `./docker/pi-web-docker --dev update` to rebuild and synchronize it. + +## 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 . +``` diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml new file mode 100644 index 0000000..27cac48 --- /dev/null +++ b/docker/compose.dev.yml @@ -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 + /usr/local/sbin/pi-web-dev-sync-node-modules + 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-volumes + + 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: diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..e619a17 --- /dev/null +++ b/docker/compose.yml @@ -0,0 +1,81 @@ +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} + 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 diff --git a/docker/custom-image.d/.gitkeep b/docker/custom-image.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/install.sh b/docker/install.sh new file mode 100755 index 0000000..4d98480 --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,499 @@ +#!/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) + --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 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 + ;; + --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() { + # POSIX sh function variables are global, so keep these names distinct + # from caller state such as write_asset's target path. + fetch_url_source=$1 + fetch_url_output=$2 + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$fetch_url_source" -o "$fetch_url_output" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$fetch_url_output" "$fetch_url_source" + 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_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_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" </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 +) diff --git a/docker/internal/bin/hostexec b/docker/internal/bin/hostexec new file mode 100755 index 0000000..182cba0 --- /dev/null +++ b/docker/internal/bin/hostexec @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: hostexec [--root] [--] + +Run a command on the Docker host by starting a temporary privileged helper +container through the mounted Docker socket and entering the host namespaces. +Commands run as the current container UID/GID by default. Use --root to keep +root privileges for administrative host commands. +EOF +} + +run_as_root=false +while [ "$#" -gt 0 ]; do + case "$1" in + --root) + run_as_root=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +if [ "$#" -eq 0 ]; then + usage + exit 64 +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 "$@" diff --git a/docker/internal/dev/compose b/docker/internal/dev/compose new file mode 100755 index 0000000..a981032 --- /dev/null +++ b/docker/internal/dev/compose @@ -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" <&2 +} + +die() { + log "pi-web Docker dev dependencies: $*" + exit 1 +} + +workspace_dir=${PI_WEB_DEV_WORKSPACE_DIR:-/workspace} +seed_dir=${PI_WEB_DEV_DEPENDENCY_SEED_DIR:-/opt/pi-web-dev-dependencies} +target_dir=$workspace_dir/node_modules +generation_file=$seed_dir/generation +marker_file=$target_dir/.pi-web-dev-dependency-generation + +# A direct Compose invocation may skip the image rebuild. Fail closed rather +# than copying dependencies for different checkout manifests. +for manifest in package.json package-lock.json; do + source_manifest=$workspace_dir/$manifest + image_manifest=$seed_dir/$manifest + [ -f "$source_manifest" ] || die "checkout is missing $source_manifest" + [ -f "$image_manifest" ] || die "development image is missing $image_manifest" + if ! cmp -s "$source_manifest" "$image_manifest"; then + die "development image dependencies do not match the checkout; run ./docker/pi-web-docker --dev start or update to rebuild the image" + fi +done + +[ -d "$seed_dir/node_modules" ] || die "development image is missing the dependency seed at $seed_dir/node_modules" +[ -s "$generation_file" ] || die "development image is missing its dependency generation at $generation_file" +[ ! -L "$target_dir" ] || die "refusing to synchronize through the node_modules symlink at $target_dir" +mkdir -p "$target_dir" + +expected_generation=$(cat "$generation_file") +current_generation= +if [ -f "$marker_file" ]; then + current_generation=$(cat "$marker_file") +fi + +if [ "$current_generation" = "$expected_generation" ]; then + log "PI WEB Docker dev dependencies are current." + exit 0 +fi + +log "Synchronizing PI WEB Docker dev dependencies from the rebuilt image ..." +# Write the marker only after a complete copy so a failed init retries next time. +find "$target_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +cp -a "$seed_dir/node_modules/." "$target_dir/" +printf '%s\n' "$expected_generation" >"$marker_file" +chmod 0666 "$marker_file" +log "PI WEB Docker dev dependencies synchronized." diff --git a/docker/internal/host-profile.sh b/docker/internal/host-profile.sh new file mode 100644 index 0000000..a043b79 --- /dev/null +++ b/docker/internal/host-profile.sh @@ -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" <&2 + rm -f "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" + return 1 + fi + pi_web_docker_host_write_volume "$control_path" "$control_path" false + fi + + cat >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <&2 + printf '%s\n' "" >&2 + printf '%s\n' "Detected:" >&2 + printf ' host OS: %s\n' "${PI_WEB_DETECTED_HOST_OS:-unknown}" >&2 + printf ' docker context: %s\n' "${PI_WEB_DETECTED_DOCKER_CONTEXT:-unknown}" >&2 + printf ' docker endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-unknown}" >&2 + printf ' DOCKER_HOST: %s\n' "${PI_WEB_DETECTED_DOCKER_HOST_ENV:-unset}" >&2 + printf ' effective endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" >&2 + printf ' docker socket source: %s\n' "${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-unknown}" >&2 + printf ' docker OS: %s\n' "${PI_WEB_DETECTED_DOCKER_OS:-unknown}" >&2 + printf '%s\n' "" >&2 + printf '%s\n' "Supported profiles:" >&2 + printf '%s\n' " - native Linux Docker Engine using /var/run/docker.sock" >&2 + printf '%s\n' " - Docker Desktop for Mac" >&2 + if [ -n "${PI_WEB_DOCKER_HOST_PROFILE_ERROR:-}" ]; then + printf '%s\n' "" >&2 + printf 'Reason: %s\n' "$PI_WEB_DOCKER_HOST_PROFILE_ERROR" >&2 + fi +} + +pi_web_docker_compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose "$@" + else + printf '%s\n' "Docker Compose is required (docker compose plugin or docker-compose)" >&2 + return 1 + fi +} diff --git a/docker/internal/image/install-opensuse-base b/docker/internal/image/install-opensuse-base new file mode 100755 index 0000000..3585e44 --- /dev/null +++ b/docker/internal/image/install-opensuse-base @@ -0,0 +1,169 @@ +#!/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 + +# Some Tumbleweed snapshots include the FIPS base pattern. This image does not +# enforce FIPS mode, and the pattern can turn normal package installs into +# interactive crypto-policy solver choices while repository metadata is in flux. +if rpm -q patterns-base-fips >/dev/null 2>&1; then + zypper --non-interactive remove patterns-base-fips +fi + +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 + vim + 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/* diff --git a/docker/pi-web-docker b/docker/pi-web-docker new file mode 100755 index 0000000..c0d8e64 --- /dev/null +++ b/docker/pi-web-docker @@ -0,0 +1,832 @@ +#!/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] [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 + (development mode requires a clean Git checkout) + status Show Docker Compose service status + logs [web|sessiond|data-init] + Follow Docker Compose logs + shell [web|sessiond] Open a shell in a service container + doctor Print static Docker command diagnostics + cli Run the pi-web CLI in the web container + +Update and restart commands launched inside a PI WEB Docker container start an +independent helper container first, then stream the helper logs inline. The +helper continues running if the terminal or 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" +} + +dev_git_operation() { + git_dir=$1 + if [ -f "$git_dir/MERGE_HEAD" ]; then + printf '%s\n' merge + elif [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ] || [ -f "$git_dir/REBASE_HEAD" ]; then + printf '%s\n' rebase + elif [ -f "$git_dir/CHERRY_PICK_HEAD" ]; then + printf '%s\n' cherry-pick + elif [ -f "$git_dir/REVERT_HEAD" ]; then + printf '%s\n' revert + elif [ -d "$git_dir/sequencer" ]; then + printf '%s\n' sequenced-operation + elif [ -f "$git_dir/BISECT_LOG" ]; then + printf '%s\n' bisect + else + return 1 + fi +} + +require_clean_dev_update_checkout() { + [ "$(docker_mode)" = dev ] || return 0 + root=$(dev_root) + require_command git + + git_root=$(git -C "$root" rev-parse --show-toplevel 2>/dev/null) \ + || die "Docker development update requires a Git checkout at $root" + git_root=$(absolute_existing_dir "$git_root") \ + || die "could not resolve Git checkout root: $git_root" + [ "$git_root" = "$root" ] \ + || die "Docker development root $root must be the Git checkout root ($git_root)" + git_dir=$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null) \ + || die "could not resolve Git metadata for $root" + + operation=$(dev_git_operation "$git_dir" 2>/dev/null || true) + if [ -n "$operation" ]; then + log "pi-web-docker: refusing to update the Docker development stack while a Git $operation is in progress: $root" + checkout_status=$(git -C "$root" status --porcelain=v1 --untracked-files=all 2>/dev/null || true) + if [ -n "$checkout_status" ]; then + log "Checkout status:" + printf '%s\n' "$checkout_status" >&2 + fi + die "resolve or abort the Git $operation before rerunning pi-web-docker --dev update" + fi + + checkout_status=$(git -C "$root" status --porcelain=v1 --untracked-files=all) \ + || die "could not inspect Git checkout status at $root" + if [ -n "$checkout_status" ]; then + log "pi-web-docker: refusing to update the Docker development stack because the checkout has uncommitted changes: $root" + log "Checkout status:" + printf '%s\n' "$checkout_status" >&2 + die "commit, stash, or remove these changes before rerunning pi-web-docker --dev update; no files were changed" + fi +} + +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 "$@" + require_clean_dev_update_checkout + case "$(docker_mode)" in + runtime) + if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then + run_runtime_host_update + fi + cache_bust=${CACHE_BUST:-pi-web-docker-$(date -u +%Y%m%dT%H%M%SZ)} + log "Building PI WEB runtime image with CACHE_BUST=$cache_bust ..." + CACHE_BUST=$cache_bust runtime_compose build --pull --no-cache + log "Recreating PI WEB runtime services ..." + runtime_compose up -d --force-recreate --remove-orphans + ;; + dev) + log "Rebuilding PI WEB development image ..." + dev_compose build --pull + log "Recreating PI WEB development services ..." + dev_compose up -d --force-recreate --remove-orphans + ;; + esac +} + +validate_logs_target() { + target=${1:-} + case "$target" in + ""|web|sessiond) return 0 ;; + data-init) + [ "$(docker_mode)" = dev ] || die "logs data-init is only available with --dev" + return 0 + ;; + *) die "logs target must be web, sessiond, or data-init" ;; + esac +} + +run_logs() { + assert_at_most_one_arg logs "$@" + require_command docker + target=${1:-} + validate_logs_target "$target" + if [ -n "$target" ]; then + compose_for_install logs -f "$target" + else + compose_for_install logs -f + fi +} + +validate_shell_target() { + target=${1:-web} + case "$target" in + web|sessiond) printf '%s\n' "$target" ;; + *) die "shell target must be web or sessiond" ;; + esac +} + +run_shell() { + assert_at_most_one_arg shell "$@" + require_command docker + target=$(validate_shell_target "${1:-web}") + compose_for_install exec "$target" bash +} + +run_doctor() { + assert_no_args doctor "$@" + root=$(control_root) + printf 'PI WEB Docker mode: %s\n' "$(docker_mode)" + printf 'PI WEB Docker root: %s\n' "$root" + case "$(docker_mode)" in + runtime) + [ -f "$root/.env" ] && printf 'Runtime env: %s\n' "$root/.env" || printf 'Runtime env: missing (%s/.env)\n' "$root" + [ -f "$root/compose.yml" ] && printf 'Runtime Compose file: %s\n' "$root/compose.yml" || printf 'Runtime Compose file: missing (%s/compose.yml)\n' "$root" + [ -f "$root/compose.override.yml" ] && printf 'Runtime Compose override: %s\n' "$root/compose.override.yml" || printf 'Runtime Compose override: missing (%s/compose.override.yml)\n' "$root" + [ -x "$root/install.sh" ] && printf 'Runtime installer: %s\n' "$root/install.sh" || printf 'Runtime installer: missing or not executable (%s/install.sh)\n' "$root" + ;; + dev) + dev_config=$root/.pi-web/docker-compose-dev.local.env + dev_env=$root/.pi-web/docker-compose-dev.generated.env + dev_override=$root/.pi-web/docker-compose-dev.host.generated.yml + dev_compose_file=$root/docker/compose.dev.yml + dev_wrapper=$root/docker/internal/dev/compose + [ -f "$dev_config" ] && printf 'Dev config: %s\n' "$dev_config" || printf 'Dev config: missing (%s)\n' "$dev_config" + [ -f "$dev_env" ] && printf 'Generated dev env: %s\n' "$dev_env" || printf 'Generated dev env: missing (%s)\n' "$dev_env" + [ -f "$dev_override" ] && printf 'Generated dev Compose override: %s\n' "$dev_override" || printf 'Generated dev Compose override: missing (%s)\n' "$dev_override" + [ -f "$dev_compose_file" ] && printf 'Dev Compose file: %s\n' "$dev_compose_file" || printf 'Dev Compose file: missing (%s)\n' "$dev_compose_file" + [ -x "$dev_wrapper" ] && printf 'Dev Compose helper: %s\n' "$dev_wrapper" || printf 'Dev Compose helper: missing or not executable (%s)\n' "$dev_wrapper" + if [ -f "$dev_env" ]; then + dev_uid=$(env_file_value "$dev_env" PI_WEB_UID 2>/dev/null || true) + dev_gid=$(env_file_value "$dev_env" PI_WEB_GID 2>/dev/null || true) + [ -n "$dev_uid" ] && printf 'Generated dev UID: %s\n' "$dev_uid" + [ -n "$dev_gid" ] && printf 'Generated dev GID: %s\n' "$dev_gid" + fi + ;; + esac + if command -v docker >/dev/null 2>&1; then + docker --version || true + if docker compose version >/dev/null 2>&1; then + docker compose version || true + elif command -v docker-compose >/dev/null 2>&1; then + docker-compose --version || true + else + printf '%s\n' 'Docker Compose: not found' + fi + else + printf '%s\n' 'Docker CLI: not found' + fi +} + +run_cli() { + [ "$#" -gt 0 ] || die "cli requires pi-web arguments" + require_command docker + compose_for_install exec web pi-web "$@" +} + +current_container_ref() { + if [ -n "${PI_WEB_DOCKER_CONTAINER_ID:-}" ]; then + printf '%s\n' "$PI_WEB_DOCKER_CONTAINER_ID" + return 0 + fi + + hostname_value=$(hostname 2>/dev/null || true) + [ -n "$hostname_value" ] || return 1 + if docker container inspect "$hostname_value" >/dev/null 2>&1; then + printf '%s\n' "$hostname_value" + return 0 + fi + + return 1 +} + +helper_image() { + env_file=$1 + case "$(docker_mode)" in + runtime) + image=$(env_file_value "$env_file" PI_WEB_IMAGE 2>/dev/null || true) + [ -n "$image" ] || image=${PI_WEB_IMAGE:-} + ;; + dev) + image=$(env_file_value "$env_file" PI_WEB_DEV_IMAGE 2>/dev/null || true) + [ -n "$image" ] || image=${PI_WEB_DEV_IMAGE:-} + ;; + esac + + if [ -z "${image:-}" ]; then + image=${PI_WEB_DOCKER_HELPER_IMAGE:-} + fi + + if [ -n "${image:-}" ]; then + printf '%s\n' "$image" + return 0 + fi + + container_ref=$(current_container_ref) || die "could not detect this Docker container; set PI_WEB_DOCKER_HELPER_IMAGE explicitly" + image=$(docker container inspect "$container_ref" --format '{{.Config.Image}}' 2>/dev/null || true) + [ -n "$image" ] && [ "$image" != "" ] || die "could not detect this container's image; set PI_WEB_DOCKER_HELPER_IMAGE explicitly" + printf '%s\n' "$image" +} + +control_env_file() { + root=$1 + case "$(docker_mode)" in + runtime) candidate=$root/.env ;; + dev) candidate=$root/.pi-web/docker-compose-dev.generated.env ;; + esac + [ -f "$candidate" ] || die "generated $(docker_mode) Docker env not found at $candidate; run pi-web-docker $(mode_flag || true) status or start from the host first" + printf '%s\n' "$candidate" +} + +control_root_env_key() { + case "$(docker_mode)" in + runtime) printf '%s\n' PI_WEB_DOCKER_INSTALL_DIR ;; + dev) printf '%s\n' PI_WEB_DOCKER_DEV_REPO_ROOT ;; + esac +} + +required_env_file_value() { + file=$1 + key=$2 + value=$(env_file_value "$file" "$key" 2>/dev/null || true) + [ -n "$value" ] || die "generated Docker env $file must define $key for detached helpers" + printf '%s\n' "$value" +} + +cleanup_old_helpers() { + root=${1:-} + project_name=${2:-} + base_filters="label=pi-web.docker-helper=true" + if [ -n "$root" ] && [ -n "$project_name" ]; then + ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter "label=pi-web.docker-helper.project=$project_name" --filter status=exited 2>/dev/null || true) + elif [ -n "$root" ]; then + ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter status=exited 2>/dev/null || true) + else + ids=$(docker ps -aq --filter "$base_filters" --filter status=exited 2>/dev/null || true) + fi + old_ids=$(docker ps -aq --filter label=pi-web.docker-control=true --filter status=exited 2>/dev/null || true) + ids="$ids $old_ids" + for id in $ids; do + [ -n "$id" ] || continue + docker rm "$id" >/dev/null 2>&1 || true + done +} + +stream_detached_helper_logs() { + helper_name=$1 + printf '\n' + printf 'Streaming detached PI WEB Docker helper logs inline.\n' + printf 'If this terminal disconnects, the helper keeps running.\n' + printf 'Reconnect with: docker logs -f %s\n' "$helper_name" + printf '\n' + + if docker logs -f "$helper_name"; then + logs_status=0 + else + logs_status=$? + fi + + if [ "$logs_status" -ne 0 ]; then + log "pi-web-docker: detached helper log streaming stopped with status $logs_status" + log "pi-web-docker: reconnect with: docker logs -f $helper_name" + return "$logs_status" + fi + + helper_status=$(docker inspect --format '{{.State.ExitCode}}' "$helper_name" 2>/dev/null || true) + if is_unsigned_int "$helper_status" && [ "$helper_status" -ne 0 ]; then + log "pi-web-docker: detached helper exited with status $helper_status" + return "$helper_status" + fi + + return 0 +} + +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_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" + stream_detached_helper_logs "$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 + # Fail before scheduling a helper, then recheck inside the helper in + # run_update so a checkout change cannot race the detached operation. + if [ "$action" = update ]; then + require_clean_dev_update_checkout + fi + start_detached_helper "$action" + return 0 + fi + + case "$action" in + update) run_update ;; + restart) run_restart_all ;; + restart-web) run_restart_web ;; + restart-sessiond) run_restart_sessiond ;; + *) die "unsupported action: $action" ;; + esac +} + +case "$command_name" in + help|-h|--help) + usage + ;; + install) + run_install "$@" + ;; + start|stop|status|logs|shell|doctor|cli|update|restart|restart-web|restart-sessiond|__run-detached) + enforce_dev_root_safety + enforce_container_mode_match + case "$command_name" in + start) run_start "$@" ;; + stop) run_stop "$@" ;; + status) run_status "$@" ;; + logs) run_logs "$@" ;; + shell) run_shell "$@" ;; + doctor) run_doctor "$@" ;; + cli) run_cli "$@" ;; + update|restart|restart-web|restart-sessiond) run_restart_or_update "$command_name" "$@" ;; + __run-detached) run_detached_action "$@" ;; + esac + ;; + *) + usage >&2 + die "unknown command: $command_name" + ;; +esac diff --git a/docs/config.html b/docs/config.html index 5a4f7c6..9cce628 100644 --- a/docs/config.html +++ b/docs/config.html @@ -3,10 +3,10 @@ - Configure PI WEB — config files, paths, and session tools + Configure PI WEB — config files, uploads, paths, and session tools @@ -14,7 +14,7 @@ @@ -23,7 +23,7 @@ @@ -80,8 +80,8 @@

Configure PI WEB where your agents work.

PI WEB configuration covers the machine-local and project-local settings you usually need: bind address, - trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload - limits, agent runtime selection, and session-daemon tools. + trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access, + manual upload defaults, upload limits, agent runtime selection, and session-daemon tools.

@@ -91,11 +91,13 @@