Archived
Merge remote-tracking branch 'origin/main' into pr-36-generic-agent-config
# Conflicts: # docs/config.html # docs/config.md # src/cli.test.ts # src/cli.ts # src/client/src/components/settings/SettingsSessiondPanel.ts # src/client/src/components/settings/settingsConfigDraft.test.ts # src/client/src/components/settings/settingsConfigDraft.ts # src/server/app.test.ts # src/server/app.ts # src/server/configRoutes.test.ts # src/server/configRoutes.ts # src/server/piWebPluginService.test.ts # src/server/piWebPluginService.ts # src/server/piWebStatus.test.ts # src/server/piWebStatus.ts # src/server/piWebStatusCache.ts # src/server/sessions/authService.test.ts # src/server/sessions/piSessionService.ts # src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <test-file>`.
|
||||
- Source or exported type changes: also run `npm run typecheck`.
|
||||
- Non-trivial test helper, component, or lint-sensitive changes: run `npx eslint <changed-file>` 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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Improve chat, prompt, and session text rendering for RTL and mixed-direction content.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Highlight within-line changes in the Git diff viewer.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -8,5 +8,9 @@ dist/
|
||||
# Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/<plugin-id>.
|
||||
/dev-plugins/
|
||||
|
||||
# Local Docker image build hooks for development containers.
|
||||
/docker/custom-image.d/*
|
||||
!/docker/custom-image.d/.gitkeep
|
||||
|
||||
# Local runtime attachment uploads (created by the chat composer "save to folder" mode).
|
||||
.pi-web/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
<project>/.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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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: `<install-dir>/data`, mounted at `/data`;
|
||||
- browser URL: <http://127.0.0.1:8504>;
|
||||
- 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 <command>`. From a checkout, run `./docker/pi-web-docker --dev <command>` for development mode. Inside PI WEB Docker containers and in the Updates panel, the command name is `pi-web-docker`; development commands include the explicit `--dev` flag, for example `pi-web-docker --dev status`.
|
||||
|
||||
| Command | Runtime/default | Development | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `install` | one-liner above or `./pi-web-docker install [installer args]` | Not available | Production bootstrap/install only; accepts the installer options below. |
|
||||
| `start` | `./pi-web-docker start` | `./docker/pi-web-docker --dev start` | Starts the split `web` and `sessiond` stack. |
|
||||
| `stop` | `./pi-web-docker stop` | `./docker/pi-web-docker --dev stop` | Stops containers without deleting persistent data. |
|
||||
| `restart` | `./pi-web-docker restart` | `./docker/pi-web-docker --dev restart` | Restarts `web` and `sessiond`. |
|
||||
| `restart-web` | `./pi-web-docker restart-web` | `./docker/pi-web-docker --dev restart-web` | Restarts only the web/API service. |
|
||||
| `restart-sessiond` | `./pi-web-docker restart-sessiond` | `./docker/pi-web-docker --dev restart-sessiond` | Restarts the session daemon; active agent runtimes may stop in that Docker stack. |
|
||||
| `update` | `./pi-web-docker update` | `./docker/pi-web-docker --dev update` | Rebuilds/recreates the stack. Runtime host updates rerun the installer to refresh Docker assets first. 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 <pi-web args...>` | `./docker/pi-web-docker --dev cli <pi-web args...>` | Proxies the existing `pi-web` CLI in the `web` container. |
|
||||
|
||||
Do not run `docker compose down -v` unless you intentionally want to remove Compose-managed volumes. The default persistent PI WEB data is a bind mount, but avoiding `-v` keeps the update/stop flow conservative.
|
||||
|
||||
### Installer options
|
||||
|
||||
The installer accepts flags and equivalent environment variables:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \
|
||||
| sh -s -- \
|
||||
--install-dir ~/.local/share/pi-web-docker \
|
||||
--data-dir ~/.local/share/pi-web-docker/data \
|
||||
--bind-address 127.0.0.1 \
|
||||
--port 8504 \
|
||||
--pi-web-version latest
|
||||
```
|
||||
|
||||
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=<git-ref>
|
||||
curl -fsSL "https://raw.githubusercontent.com/jmfederico/pi-web/$ref/docker/install.sh" \
|
||||
| sh -s -- --asset-ref "$ref"
|
||||
```
|
||||
|
||||
## Localhost binding and remote access
|
||||
|
||||
The runtime listens on `0.0.0.0:8504` inside the container but publishes it to `127.0.0.1:8504` on the host by default.
|
||||
|
||||
For SSH access from your laptop:
|
||||
|
||||
```bash
|
||||
ssh -L 8504:127.0.0.1:8504 user@server
|
||||
# open http://127.0.0.1:8504 locally
|
||||
```
|
||||
|
||||
For a trusted VPN/private interface, bind to that private address:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh \
|
||||
| sh -s -- --bind-address 100.x.y.z --port 8504
|
||||
```
|
||||
|
||||
If you use a reverse proxy, keep the container bound to localhost or a private address and put authentication/TLS at the proxy. Avoid `--bind-address 0.0.0.0` unless another trusted layer restricts access.
|
||||
|
||||
## `hostexec` examples
|
||||
|
||||
`hostexec [--root] <command...>` is the native Linux host command bridge provided by this Docker setup. It is enabled only for the `linux-native-docker` profile and intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands.
|
||||
|
||||
On Docker Desktop for Mac, `hostexec` exits with a clear disabled message because the Docker daemon and containers run inside a Linux VM, not in native macOS namespaces. Docker CLI and Docker Compose commands still work through the mounted Docker socket.
|
||||
|
||||
Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container on native Linux:
|
||||
|
||||
```bash
|
||||
hostexec uname -a
|
||||
hostexec systemctl status docker
|
||||
hostexec --root zypper refresh
|
||||
hostexec --root sh -lc 'zypper refresh && zypper dup -y'
|
||||
hostexec --root apt-get update
|
||||
```
|
||||
|
||||
From the host shell, for a quick smoke test:
|
||||
|
||||
```bash
|
||||
cd ~/.local/share/pi-web-docker
|
||||
docker compose exec web hostexec uname -a
|
||||
```
|
||||
|
||||
On native Linux, `hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user.
|
||||
|
||||
## Development Docker setup
|
||||
|
||||
Use this mode when developing PI WEB from this checkout. It bind-mounts the source tree, keeps dependencies in a Docker volume, stores PI WEB/Pi data in the same host data directory as runtime mode by default, and preserves the split runtime model:
|
||||
|
||||
- `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes;
|
||||
- `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`.
|
||||
|
||||
From the repository root, use the canonical Docker command so the same fail-closed host profile detection is applied as runtime mode:
|
||||
|
||||
```bash
|
||||
./docker/pi-web-docker --dev start
|
||||
```
|
||||
|
||||
The command creates `.pi-web/docker-compose-dev.local.env` on first run, writes `.pi-web/docker-compose-dev.generated.env` and `.pi-web/docker-compose-dev.host.generated.yml`, then runs Docker Compose with `docker/compose.dev.yml` plus that generated host override. The generated environment includes the host repository root as `PI_WEB_DOCKER_DEV_REPO_ROOT`, and the generated override mounts that path back into the containers so Docker helper commands can run Compose from the same absolute path. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the command.
|
||||
|
||||
Values used by the command are resolved in this order:
|
||||
|
||||
1. `.pi-web/docker-compose-dev.local.env`;
|
||||
2. previous generated values in `.pi-web/docker-compose-dev.generated.env`, when present;
|
||||
3. current shell environment, on first generation only;
|
||||
4. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`;
|
||||
5. built-in defaults.
|
||||
|
||||
`COMPOSE_PROJECT_NAME`, `PI_WEB_UID`, and `PI_WEB_GID` are the exceptions to runtime-env reuse. Development mode defaults the Compose project to `pi-web-dev` and defaults the container user/group to the current host user, unless you set values in the shell or `.pi-web/docker-compose-dev.local.env`. This keeps development and runtime stacks from accidentally sharing one Docker Compose project and prevents bind-mounted checkout files from being written as root or as a different runtime service user.
|
||||
|
||||
If you already ran the runtime installer, dev mode therefore reuses shared defaults such as Docker group, data directory, extra host paths, image build inputs, upload limit, and bind address unless you set a more specific value in the shell or `.local.env`. If an older `.pi-web/docker-compose-dev.env` exists, the first run copies its dev bind/port values into `.local.env` so previous local exposure settings are easy to see and edit.
|
||||
|
||||
To expose the dev API and Vite UI beyond localhost persistently, edit `.pi-web/docker-compose-dev.local.env`:
|
||||
|
||||
```dotenv
|
||||
PI_WEB_DEV_API_BIND_ADDR=0.0.0.0
|
||||
PI_WEB_DEV_BIND_ADDR=0.0.0.0
|
||||
```
|
||||
|
||||
For temporary overrides, prefix the command:
|
||||
|
||||
```bash
|
||||
PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 \
|
||||
PI_WEB_DEV_BIND_ADDR=0.0.0.0 \
|
||||
./docker/pi-web-docker --dev start
|
||||
```
|
||||
|
||||
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 <http://127.0.0.1:8505>. The dev API is published on <http://127.0.0.1:8504>.
|
||||
|
||||
Useful development commands:
|
||||
|
||||
```bash
|
||||
./docker/pi-web-docker --dev status
|
||||
./docker/pi-web-docker --dev logs web
|
||||
./docker/pi-web-docker --dev logs data-init
|
||||
./docker/pi-web-docker --dev restart-web
|
||||
./docker/pi-web-docker --dev restart-sessiond
|
||||
./docker/pi-web-docker --dev update
|
||||
./docker/pi-web-docker --dev stop
|
||||
```
|
||||
|
||||
Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads. Commands launched from the Updates panel use the same detached `pi-web-docker` helper as runtime mode, 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/<repo>`, `/srv/<project>`, or `/opt/<project>`. On Mac, prefer paths under `/Users/<you>/...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path.
|
||||
|
||||
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 .
|
||||
```
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Executable
+499
@@ -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" <<EOF
|
||||
# Generated by the PI WEB Docker installer.
|
||||
# Re-run install.sh to refresh Docker assets and update the local image.
|
||||
# Persistent data lives in PI_WEB_DOCKER_DATA_DIR and is not deleted by updates.
|
||||
|
||||
# Host identity used for the runtime containers and image user account.
|
||||
PI_WEB_UID=$pi_web_uid
|
||||
PI_WEB_GID=$pi_web_gid
|
||||
DOCKER_GID=$docker_gid
|
||||
|
||||
# Detected Docker host profile and host capability toggles.
|
||||
PI_WEB_DOCKER_HOST_PROFILE=$pi_web_host_profile
|
||||
HOSTEXEC_MODE=$hostexec_mode
|
||||
PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths_env
|
||||
|
||||
# Persistent data, Docker control root, and localhost-only default exposure.
|
||||
PI_WEB_DOCKER_DATA_DIR=$data_dir
|
||||
PI_WEB_DOCKER_INSTALL_DIR=$install_dir
|
||||
PI_WEB_DOCKER_REF=$asset_ref
|
||||
PI_WEB_BIND_ADDR=$pi_web_bind_addr
|
||||
PI_WEB_PORT=$pi_web_port
|
||||
|
||||
# npm package selection. Pi resolves from PI WEB's npm peer dependency.
|
||||
PI_WEB_VERSION=$pi_web_version
|
||||
|
||||
# openSUSE/Node.js image build inputs.
|
||||
PI_WEB_OPENSUSE_IMAGE=$pi_web_opensuse_image
|
||||
PI_WEB_NODEJS_MAJOR=$pi_web_nodejs_major
|
||||
PI_WEB_NODEJS_REPO=$pi_web_nodejs_repo
|
||||
PI_WEB_EXTRA_ZYPPER_PACKAGES=$pi_web_extra_zypper_packages_env
|
||||
|
||||
# Runtime image names, Compose project, and limits.
|
||||
PI_WEB_IMAGE=$pi_web_image
|
||||
COMPOSE_PROJECT_NAME=$compose_project_name
|
||||
HOSTEXEC_IMAGE=$hostexec_image
|
||||
PI_WEB_MAX_UPLOAD_BYTES=$pi_web_max_upload_bytes
|
||||
EOF
|
||||
mv "$temp_env" "$env_file"
|
||||
|
||||
log "Wrote Docker assets to $install_dir"
|
||||
log "Wrote runtime environment to $env_file"
|
||||
log "Wrote host Compose override to $compose_override_file"
|
||||
log "Selected PI WEB Docker host profile: $pi_web_host_profile"
|
||||
case "$pi_web_host_profile" in
|
||||
linux-native-docker)
|
||||
log "Enabled Linux host mounts and hostexec namespace bridge."
|
||||
;;
|
||||
mac-docker-desktop)
|
||||
log "Enabled Docker Desktop for Mac project mounts. hostexec is disabled because containers cannot enter native macOS namespaces."
|
||||
;;
|
||||
esac
|
||||
log "Persistent PI WEB Docker data: $data_dir"
|
||||
log "Custom image hooks: $custom_image_hooks_dir"
|
||||
|
||||
if [ "${PI_WEB_DOCKER_SKIP_COMPOSE:-0}" = 1 ]; then
|
||||
log "Skipping Docker build/recreate because PI_WEB_DOCKER_SKIP_COMPOSE=1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
die "docker CLI is required"
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
die "docker daemon is not reachable by this user"
|
||||
fi
|
||||
|
||||
cache_bust=${CACHE_BUST:-install-$(date -u +%Y%m%dT%H%M%SZ)}
|
||||
|
||||
log ""
|
||||
log "WARNING: updating recreates the PI WEB Docker session daemon."
|
||||
log "Active Pi agent runtimes inside this Docker install can stop; update while sessions are idle."
|
||||
log "Persistent data under $data_dir is kept. The installer does not run 'docker compose down -v'."
|
||||
log ""
|
||||
log "Building $pi_web_image with --pull --no-cache (CACHE_BUST=$cache_bust) ..."
|
||||
(
|
||||
cd "$install_dir"
|
||||
CACHE_BUST=$cache_bust run_runtime_compose build --pull --no-cache
|
||||
)
|
||||
|
||||
log "Recreating split PI WEB Docker services ..."
|
||||
(
|
||||
cd "$install_dir"
|
||||
run_runtime_compose up -d --force-recreate --remove-orphans
|
||||
)
|
||||
|
||||
log ""
|
||||
log "PI WEB Docker runtime is ready: http://$pi_web_bind_addr:$pi_web_port"
|
||||
log "Install directory: $install_dir"
|
||||
log "To update later, run: $install_dir/pi-web-docker update"
|
||||
(
|
||||
cd "$install_dir"
|
||||
run_runtime_compose ps
|
||||
)
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: hostexec [--root] [--] <command...>
|
||||
|
||||
Run a command on the Docker host by starting a temporary privileged helper
|
||||
container through the mounted Docker socket and entering the host namespaces.
|
||||
Commands run as the current container UID/GID by default. Use --root to keep
|
||||
root privileges for administrative host commands.
|
||||
EOF
|
||||
}
|
||||
|
||||
run_as_root=false
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root)
|
||||
run_as_root=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
usage
|
||||
exit 64
|
||||
fi
|
||||
|
||||
hostexec_mode="${HOSTEXEC_MODE:-nsenter}"
|
||||
case "$hostexec_mode" in
|
||||
nsenter) ;;
|
||||
disabled|none)
|
||||
echo "hostexec: disabled for this Docker host profile" >&2
|
||||
echo "hostexec: on Docker Desktop for Mac, containers run inside a Linux VM and cannot enter native macOS namespaces" >&2
|
||||
exit 69
|
||||
;;
|
||||
*)
|
||||
echo "hostexec: unsupported HOSTEXEC_MODE: $hostexec_mode" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "hostexec: docker CLI not found in this container" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
docker_host="${DOCKER_HOST:-unix:///var/run/docker.sock}"
|
||||
if [[ "$docker_host" == unix://* ]]; then
|
||||
socket_path="${docker_host#unix://}"
|
||||
if [ ! -S "$socket_path" ]; then
|
||||
echo "hostexec: Docker socket is not accessible as a Unix socket at $socket_path" >&2
|
||||
exit 69
|
||||
fi
|
||||
fi
|
||||
|
||||
helper_image="${HOSTEXEC_IMAGE:-alpine:3.22}"
|
||||
target_uid="$(id -u)"
|
||||
target_gid="$(id -g)"
|
||||
tty_args=(--interactive)
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
tty_args+=(--tty)
|
||||
fi
|
||||
|
||||
docker_args=(
|
||||
--rm
|
||||
"${tty_args[@]}"
|
||||
--pull=missing
|
||||
--privileged
|
||||
--security-opt label=disable
|
||||
--pid=host
|
||||
--network=host
|
||||
--volume /:/host:rw
|
||||
)
|
||||
|
||||
if [ "$run_as_root" = true ] || { [ "$target_uid" = 0 ] && [ "$target_gid" = 0 ]; }; then
|
||||
exec docker run \
|
||||
"${docker_args[@]}" \
|
||||
"$helper_image" \
|
||||
nsenter -t 1 -m -u -i -n -p -- "$@"
|
||||
fi
|
||||
|
||||
run_as_container_user='target_uid="${HOSTEXEC_TARGET_UID:?}"
|
||||
target_gid="${HOSTEXEC_TARGET_GID:?}"
|
||||
|
||||
target_user=""
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
passwd_entry="$(getent passwd "$target_uid" || true)"
|
||||
if [ -n "$passwd_entry" ]; then
|
||||
target_user="${passwd_entry%%:*}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$target_user" ]; then
|
||||
if command -v runuser >/dev/null 2>&1; then
|
||||
exec runuser -u "$target_user" -- "$@"
|
||||
fi
|
||||
|
||||
if command -v su >/dev/null 2>&1; then
|
||||
exec su -s /bin/sh -c '\''exec "$@"'\'' -- "$target_user" hostexec-su "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v setpriv >/dev/null 2>&1; then
|
||||
if [ -n "$target_user" ]; then
|
||||
exec setpriv --reuid "$target_uid" --regid "$target_gid" --init-groups -- "$@"
|
||||
fi
|
||||
|
||||
exec setpriv --reuid "$target_uid" --regid "$target_gid" --clear-groups -- "$@"
|
||||
fi
|
||||
|
||||
if command -v nsenter >/dev/null 2>&1; then
|
||||
exec nsenter -t 1 -m -u -i -n -p -S "$target_uid" -G "$target_gid" -- "$@"
|
||||
fi
|
||||
|
||||
echo "hostexec: unable to switch to host uid:gid $target_uid:$target_gid" >&2
|
||||
exit 69
|
||||
'
|
||||
|
||||
exec docker run \
|
||||
"${docker_args[@]}" \
|
||||
--env HOSTEXEC_TARGET_UID="$target_uid" \
|
||||
--env HOSTEXEC_TARGET_GID="$target_gid" \
|
||||
"$helper_image" \
|
||||
nsenter -t 1 -m -u -i -n -p -- /bin/sh -c "$run_as_container_user" hostexec-user "$@"
|
||||
Executable
+313
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
log "pi-web Docker dev compose: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
script_dir=$(unset CDPATH; cd "$(dirname "$0")" && pwd -P)
|
||||
repo_root=$(unset CDPATH; cd "$script_dir/../../.." && pwd -P)
|
||||
dev_config_file=$repo_root/.pi-web/docker-compose-dev.local.env
|
||||
legacy_dev_env_file=$repo_root/.pi-web/docker-compose-dev.env
|
||||
generated_env_file=$repo_root/.pi-web/docker-compose-dev.generated.env
|
||||
|
||||
# shellcheck source=../host-profile.sh
|
||||
# shellcheck disable=SC1091
|
||||
. "$repo_root/docker/internal/host-profile.sh"
|
||||
|
||||
strip_wrapping_quotes() {
|
||||
value=$1
|
||||
case "$value" in
|
||||
\"*\")
|
||||
case "$value" in
|
||||
*\") value=${value#\"}; value=${value%\"} ;;
|
||||
esac
|
||||
;;
|
||||
\'*\')
|
||||
case "$value" in
|
||||
*\') value=${value#\'}; value=${value%\'} ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
env_file_value() {
|
||||
file=$1
|
||||
key=$2
|
||||
[ -f "$file" ] || return 1
|
||||
raw=$(awk -v key="$key" '
|
||||
function trim(value) {
|
||||
sub(/^[ \t]+/, "", value)
|
||||
sub(/[ \t\r]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
/^[ \t]*(#|$)/ { next }
|
||||
{
|
||||
line = $0
|
||||
sub(/^[ \t]*export[ \t]+/, "", line)
|
||||
name = line
|
||||
sub(/=.*/, "", name)
|
||||
name = trim(name)
|
||||
if (name == key) {
|
||||
sub(/^[^=]*=/, "", line)
|
||||
print trim(line)
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
}
|
||||
END { if (!found) exit 1 }
|
||||
' "$file") || return 1
|
||||
strip_wrapping_quotes "$raw"
|
||||
}
|
||||
|
||||
dev_config_value() {
|
||||
env_file_value "$dev_config_file" "$1"
|
||||
}
|
||||
|
||||
runtime_env_value() {
|
||||
env_file_value "$runtime_env_file" "$1"
|
||||
}
|
||||
|
||||
generated_env_value() {
|
||||
env_file_value "$generated_env_file" "$1"
|
||||
}
|
||||
|
||||
write_initial_dev_config() {
|
||||
[ ! -e "$dev_config_file" ] || return 0
|
||||
|
||||
temp_config=$dev_config_file.$$
|
||||
previous_umask=$(umask)
|
||||
umask 077
|
||||
cat >"$temp_config" <<'EOF'
|
||||
# PI WEB Docker dev settings. Safe to edit.
|
||||
#
|
||||
# docker/pi-web-docker --dev creates this file once and does not
|
||||
# overwrite it. Put persistent dev Docker settings here.
|
||||
#
|
||||
# Precedence for values used by docker/pi-web-docker --dev:
|
||||
# 1. this file
|
||||
# 2. previous generated values, when present
|
||||
# 3. current shell environment, on first generation only
|
||||
# 4. runtime installer env, usually ~/.local/share/pi-web-docker/.env
|
||||
# 5. built-in defaults
|
||||
#
|
||||
# Generated effective values are written to:
|
||||
# .pi-web/docker-compose-dev.generated.env
|
||||
#
|
||||
# Bind addresses:
|
||||
# - 127.0.0.1 exposes only to this machine.
|
||||
# - 0.0.0.0 exposes on all host interfaces. Use only on trusted networks.
|
||||
#
|
||||
# Uncomment or add values to persist them. PI_WEB_DEV_API_BIND_ADDR
|
||||
# controls the web/API server; PI_WEB_DEV_BIND_ADDR controls the Vite UI.
|
||||
# PI_WEB_DEV_API_BIND_ADDR=127.0.0.1
|
||||
# PI_WEB_DEV_BIND_ADDR=127.0.0.1
|
||||
# PI_WEB_DEV_API_PORT=8504
|
||||
# PI_WEB_DEV_PORT=8505
|
||||
#
|
||||
# Shared Docker/runtime-style defaults may also be set here:
|
||||
# PI_WEB_DOCKER_DATA_DIR=/absolute/path/to/pi-web-docker/data
|
||||
# PI_WEB_DOCKER_EXTRA_HOST_PATHS="/absolute/path/one /absolute/path/two"
|
||||
#
|
||||
# PI_WEB_UID and PI_WEB_GID default to the current host user so
|
||||
# bind-mounted checkout files are not written as root or another user.
|
||||
# Set them here only if you intentionally want a different container user.
|
||||
EOF
|
||||
umask "$previous_umask"
|
||||
|
||||
if [ -f "$legacy_dev_env_file" ]; then
|
||||
{
|
||||
printf '\n%s\n' "# Values copied from the previous generated dev env file."
|
||||
printf '%s\n' "# Keep, edit, or delete these lines as needed."
|
||||
for key in PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT; do
|
||||
if value=$(env_file_value "$legacy_dev_env_file" "$key"); then
|
||||
printf '%s=%s\n' "$key" "$value"
|
||||
fi
|
||||
done
|
||||
} >>"$temp_config"
|
||||
fi
|
||||
|
||||
mv "$temp_config" "$dev_config_file"
|
||||
log "Created user-editable dev config: $dev_config_file"
|
||||
}
|
||||
|
||||
value_from_config_or_generated_or_env_or_runtime_or_default() {
|
||||
key=$1
|
||||
default_value=$2
|
||||
if existing=$(dev_config_value "$key"); then
|
||||
printf '%s\n' "$existing"
|
||||
elif existing=$(generated_env_value "$key"); then
|
||||
printf '%s\n' "$existing"
|
||||
else
|
||||
eval "is_set=\${$key+x}"
|
||||
if [ "${is_set:-}" = x ]; then
|
||||
eval "printf '%s\n' \"\${$key}\""
|
||||
elif existing=$(runtime_env_value "$key"); then
|
||||
printf '%s\n' "$existing"
|
||||
else
|
||||
printf '%s\n' "$default_value"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
value_from_config_or_generated_or_env_or_default() {
|
||||
key=$1
|
||||
default_value=$2
|
||||
if existing=$(dev_config_value "$key"); then
|
||||
printf '%s\n' "$existing"
|
||||
elif existing=$(generated_env_value "$key"); then
|
||||
printf '%s\n' "$existing"
|
||||
else
|
||||
eval "is_set=\${$key+x}"
|
||||
if [ "${is_set:-}" = x ]; then
|
||||
eval "printf '%s\n' \"\${$key}\""
|
||||
else
|
||||
printf '%s\n' "$default_value"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
is_truthy() {
|
||||
case "${1:-}" in
|
||||
""|0|false|FALSE|False) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_unsigned_int() {
|
||||
case "${1:-}" in
|
||||
""|*[!0-9]*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_unsigned_int() {
|
||||
name=$1
|
||||
value=$2
|
||||
is_unsigned_int "$value" || die "$name must be a numeric Unix id, got: $value"
|
||||
}
|
||||
|
||||
enforce_dev_root_safety() {
|
||||
uid=$(id -u 2>/dev/null || printf '0')
|
||||
[ "$uid" != 0 ] || is_truthy "${PI_WEB_DOCKER_ALLOW_ROOT:-0}" || die "refusing to run Docker development mode as root; retry with --allow-root if this is intentional"
|
||||
}
|
||||
|
||||
enforce_non_root_dev_uid() {
|
||||
[ "${1:-0}" -ne 0 ] || is_truthy "${PI_WEB_DOCKER_ALLOW_ROOT:-0}" || die "refusing to generate Docker development env with PI_WEB_UID=0; retry with --allow-root if this is intentional"
|
||||
}
|
||||
|
||||
enforce_dev_root_safety
|
||||
|
||||
if ! pi_web_docker_host_detect_profile; then
|
||||
pi_web_docker_host_print_detection_failure
|
||||
die "refusing to run Docker Compose for an unsupported or unknown host setup"
|
||||
fi
|
||||
|
||||
runtime_env_file=${PI_WEB_DOCKER_RUNTIME_ENV_FILE:-}
|
||||
if [ -z "$runtime_env_file" ] && [ -n "${HOME:-}" ]; then
|
||||
runtime_env_file=$HOME/.local/share/pi-web-docker/.env
|
||||
fi
|
||||
|
||||
mkdir -p "$repo_root/.pi-web" || die "could not create .pi-web directory"
|
||||
write_initial_dev_config
|
||||
|
||||
host_uid=$(id -u 2>/dev/null || printf '0')
|
||||
host_gid=$(id -g 2>/dev/null || printf '0')
|
||||
pi_web_uid=$(value_from_config_or_generated_or_env_or_default PI_WEB_UID "$host_uid")
|
||||
pi_web_gid=$(value_from_config_or_generated_or_env_or_default PI_WEB_GID "$host_gid")
|
||||
docker_gid=$(value_from_config_or_generated_or_env_or_runtime_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)")
|
||||
default_data_dir=${HOME:-$repo_root/.pi-web}/.local/share/pi-web-docker/data
|
||||
pi_web_data_dir=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir")
|
||||
pi_web_extra_host_paths=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
|
||||
pi_web_opensuse_image=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed)
|
||||
pi_web_nodejs_major=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22)
|
||||
pi_web_nodejs_repo=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_NODEJS_REPO auto)
|
||||
pi_web_extra_zypper_packages=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
|
||||
pi_web_dev_image=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev)
|
||||
compose_project_name=$(value_from_config_or_generated_or_env_or_default COMPOSE_PROJECT_NAME pi-web-dev)
|
||||
hostexec_image=$(value_from_config_or_generated_or_env_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22)
|
||||
pi_web_max_upload_bytes=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
|
||||
default_dev_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1)
|
||||
pi_web_dev_api_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr")
|
||||
pi_web_dev_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr")
|
||||
pi_web_dev_api_port=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_API_PORT 8504)
|
||||
pi_web_dev_port=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_PORT 8505)
|
||||
|
||||
require_unsigned_int PI_WEB_UID "$pi_web_uid"
|
||||
require_unsigned_int PI_WEB_GID "$pi_web_gid"
|
||||
require_unsigned_int DOCKER_GID "$docker_gid"
|
||||
enforce_non_root_dev_uid "$pi_web_uid"
|
||||
case "$pi_web_data_dir" in
|
||||
/*) ;;
|
||||
*) die "PI_WEB_DOCKER_DATA_DIR must be an absolute path, got: $pi_web_data_dir" ;;
|
||||
esac
|
||||
[ -n "$compose_project_name" ] || die "COMPOSE_PROJECT_NAME must not be empty"
|
||||
|
||||
mkdir -p "$pi_web_data_dir" || die "could not create data directory: $pi_web_data_dir"
|
||||
|
||||
env_file=$generated_env_file
|
||||
override_file=$repo_root/.pi-web/docker-compose-dev.host.generated.yml
|
||||
|
||||
if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths" "$repo_root"; then
|
||||
die "could not write host-specific Compose override"
|
||||
fi
|
||||
|
||||
umask 077
|
||||
temp_env=$env_file.$$
|
||||
cat >"$temp_env" <<EOF
|
||||
# Generated by docker/pi-web-docker --dev. Do not edit by hand.
|
||||
PI_WEB_UID=$pi_web_uid
|
||||
PI_WEB_GID=$pi_web_gid
|
||||
DOCKER_GID=$docker_gid
|
||||
PI_WEB_DOCKER_DATA_DIR=$pi_web_data_dir
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root
|
||||
PI_WEB_DOCKER_HOST_PROFILE=$PI_WEB_DETECTED_DOCKER_HOST_PROFILE
|
||||
HOSTEXEC_MODE=$PI_WEB_DETECTED_HOSTEXEC_MODE
|
||||
PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths
|
||||
PI_WEB_OPENSUSE_IMAGE=$pi_web_opensuse_image
|
||||
PI_WEB_NODEJS_MAJOR=$pi_web_nodejs_major
|
||||
PI_WEB_NODEJS_REPO=$pi_web_nodejs_repo
|
||||
PI_WEB_EXTRA_ZYPPER_PACKAGES=$pi_web_extra_zypper_packages
|
||||
PI_WEB_DEV_IMAGE=$pi_web_dev_image
|
||||
COMPOSE_PROJECT_NAME=$compose_project_name
|
||||
HOSTEXEC_IMAGE=$hostexec_image
|
||||
PI_WEB_MAX_UPLOAD_BYTES=$pi_web_max_upload_bytes
|
||||
PI_WEB_DEV_API_BIND_ADDR=$pi_web_dev_api_bind_addr
|
||||
PI_WEB_DEV_BIND_ADDR=$pi_web_dev_bind_addr
|
||||
PI_WEB_DEV_API_PORT=$pi_web_dev_api_port
|
||||
PI_WEB_DEV_PORT=$pi_web_dev_port
|
||||
EOF
|
||||
mv "$temp_env" "$env_file"
|
||||
|
||||
log "Selected PI WEB Docker host profile: $PI_WEB_DETECTED_DOCKER_HOST_PROFILE"
|
||||
if [ -f "$runtime_env_file" ]; then
|
||||
log "Reused runtime Docker environment defaults from: $runtime_env_file"
|
||||
fi
|
||||
case "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" in
|
||||
linux-native-docker)
|
||||
log "Enabled Linux host mounts and hostexec namespace bridge."
|
||||
;;
|
||||
mac-docker-desktop)
|
||||
log "Enabled Docker Desktop for Mac project mounts. hostexec is disabled because containers cannot enter native macOS namespaces."
|
||||
;;
|
||||
esac
|
||||
log "User-editable dev config: $dev_config_file"
|
||||
log "Generated dev env: $env_file"
|
||||
log "Generated dev Compose override: $override_file"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set -- up --build
|
||||
fi
|
||||
|
||||
pi_web_docker_compose \
|
||||
--project-name "$compose_project_name" \
|
||||
--env-file "$env_file" \
|
||||
-f "$repo_root/docker/compose.dev.yml" \
|
||||
-f "$override_file" \
|
||||
"$@"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*" >&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."
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env sh
|
||||
# shellcheck disable=SC2034
|
||||
|
||||
pi_web_docker_host_yaml_quote() {
|
||||
value=$1
|
||||
escaped=$(printf '%s' "$value" | sed "s/'/''/g")
|
||||
printf "'%s'" "$escaped"
|
||||
}
|
||||
|
||||
pi_web_docker_host_socket_path_from_endpoint() {
|
||||
endpoint=$1
|
||||
case "$endpoint" in
|
||||
unix://*) printf '%s\n' "${endpoint#unix://}" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pi_web_docker_host_mac_desktop_socket_path() {
|
||||
[ -n "${HOME:-}" ] || return 1
|
||||
printf '%s/.docker/run/docker.sock\n' "$HOME"
|
||||
}
|
||||
|
||||
pi_web_docker_host_endpoint_is_linux_expected() {
|
||||
endpoint=$1
|
||||
[ "$endpoint" = unix:///var/run/docker.sock ]
|
||||
}
|
||||
|
||||
pi_web_docker_host_endpoint_is_mac_expected() {
|
||||
endpoint=$1
|
||||
if ! socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$endpoint" 2>/dev/null); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$socket_path" in
|
||||
/var/run/docker.sock)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null); then
|
||||
[ "$socket_path" = "$mac_socket_path" ] && return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
pi_web_docker_host_socket_source_for_endpoint() {
|
||||
endpoint=$1
|
||||
pi_web_docker_host_socket_path_from_endpoint "$endpoint"
|
||||
}
|
||||
|
||||
pi_web_docker_host_detect_docker_gid() {
|
||||
case "${PI_WEB_DETECTED_DOCKER_HOST_PROFILE:-}" in
|
||||
mac-docker-desktop)
|
||||
printf '0\n'
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
socket_path=/var/run/docker.sock
|
||||
if [ -n "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-}" ]; then
|
||||
if detected_socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$PI_WEB_DETECTED_DOCKER_ENDPOINT" 2>/dev/null); then
|
||||
socket_path=$detected_socket_path
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -S "$socket_path" ]; then
|
||||
if gid=$(stat -c '%g' "$socket_path" 2>/dev/null); then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
if gid=$(stat -f '%g' "$socket_path" 2>/dev/null); then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then
|
||||
if [ -n "$gid" ]; then
|
||||
printf '%s\n' "$gid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '0\n'
|
||||
}
|
||||
|
||||
pi_web_docker_host_detect_profile() {
|
||||
PI_WEB_DETECTED_HOST_OS=$(uname -s 2>/dev/null || printf 'unknown')
|
||||
PI_WEB_DETECTED_DOCKER_CONTEXT=
|
||||
PI_WEB_DETECTED_DOCKER_ENDPOINT=
|
||||
PI_WEB_DETECTED_DOCKER_HOST_ENV=${DOCKER_HOST:-}
|
||||
PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=
|
||||
PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=
|
||||
PI_WEB_DETECTED_DOCKER_OS=
|
||||
PI_WEB_DETECTED_DOCKER_HOST_PROFILE=
|
||||
PI_WEB_DETECTED_HOSTEXEC_MODE=disabled
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR=
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker CLI is required"
|
||||
return 1
|
||||
fi
|
||||
|
||||
PI_WEB_DETECTED_DOCKER_CONTEXT=$(docker context show 2>/dev/null || printf 'unknown')
|
||||
if [ -n "$PI_WEB_DETECTED_DOCKER_CONTEXT" ] && [ "$PI_WEB_DETECTED_DOCKER_CONTEXT" != unknown ]; then
|
||||
PI_WEB_DETECTED_DOCKER_ENDPOINT=$(docker context inspect "$PI_WEB_DETECTED_DOCKER_CONTEXT" --format '{{if .Endpoints.docker}}{{.Endpoints.docker.Host}}{{end}}' 2>/dev/null || printf '')
|
||||
fi
|
||||
|
||||
case "$PI_WEB_DETECTED_HOST_OS" in
|
||||
Linux)
|
||||
if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require DOCKER_HOST to be unset or exactly unix:///var/run/docker.sock, not $PI_WEB_DETECTED_DOCKER_HOST_ENV"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require the local /var/run/docker.sock Docker context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT"
|
||||
return 1
|
||||
fi
|
||||
|
||||
PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=${PI_WEB_DETECTED_DOCKER_HOST_ENV:-$PI_WEB_DETECTED_DOCKER_ENDPOINT}
|
||||
PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock
|
||||
if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require a local Docker socket at /var/run/docker.sock"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
Darwin)
|
||||
if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ]; then
|
||||
if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require DOCKER_HOST to be unset or a Docker Desktop local Unix socket, not $PI_WEB_DETECTED_DOCKER_HOST_ENV"
|
||||
return 1
|
||||
fi
|
||||
PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_HOST_ENV
|
||||
else
|
||||
PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_ENDPOINT
|
||||
fi
|
||||
|
||||
if [ -n "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" ]; then
|
||||
if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT"; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket, not ${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}"
|
||||
return 1
|
||||
fi
|
||||
PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$(pi_web_docker_host_socket_source_for_endpoint "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT") || return 1
|
||||
elif mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null) && [ -S "$mac_socket_path" ]; then
|
||||
PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$mac_socket_path
|
||||
else
|
||||
PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock
|
||||
fi
|
||||
|
||||
if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop socket is not accessible at $PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="unsupported host OS: $PI_WEB_DETECTED_HOST_OS"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker daemon is not reachable by this user"
|
||||
return 1
|
||||
fi
|
||||
PI_WEB_DETECTED_DOCKER_OS=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || printf '')
|
||||
|
||||
case "$PI_WEB_DETECTED_HOST_OS" in
|
||||
Linux)
|
||||
case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS" in
|
||||
*desktop-linux*|*"Docker Desktop"*)
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop on Linux is not supported by this installer because it runs containers inside a VM instead of the native Linux host"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
PI_WEB_DETECTED_DOCKER_HOST_PROFILE=linux-native-docker
|
||||
PI_WEB_DETECTED_HOSTEXEC_MODE=nsenter
|
||||
;;
|
||||
Darwin)
|
||||
case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS:$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" in
|
||||
*desktop-linux*|*"Docker Desktop"*|*"/.docker/run/docker.sock"*)
|
||||
PI_WEB_DETECTED_DOCKER_HOST_PROFILE=mac-docker-desktop
|
||||
PI_WEB_DETECTED_HOSTEXEC_MODE=disabled
|
||||
;;
|
||||
*)
|
||||
PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs currently require Docker Desktop; detected context '$PI_WEB_DETECTED_DOCKER_CONTEXT' endpoint '${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}'"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
pi_web_docker_host_write_volume() {
|
||||
source_path=$1
|
||||
target_path=$2
|
||||
read_only=${3:-false}
|
||||
|
||||
{
|
||||
printf ' - type: bind\n'
|
||||
printf ' source: %s\n' "$(pi_web_docker_host_yaml_quote "$source_path")"
|
||||
printf ' target: %s\n' "$(pi_web_docker_host_yaml_quote "$target_path")"
|
||||
if [ "$read_only" = true ]; then
|
||||
printf ' read_only: true\n'
|
||||
fi
|
||||
} >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
|
||||
}
|
||||
|
||||
pi_web_docker_host_write_existing_volume() {
|
||||
source_path=$1
|
||||
target_path=$2
|
||||
read_only=${3:-false}
|
||||
|
||||
if [ -e "$source_path" ]; then
|
||||
pi_web_docker_host_write_volume "$source_path" "$target_path" "$read_only"
|
||||
fi
|
||||
}
|
||||
|
||||
pi_web_docker_host_write_extra_volumes() {
|
||||
extra_paths=$1
|
||||
|
||||
for extra_path in $extra_paths; do
|
||||
case "$extra_path" in
|
||||
/*) ;;
|
||||
*)
|
||||
printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entries must be absolute paths: $extra_path" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -e "$extra_path" ]; then
|
||||
printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entry does not exist: $extra_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
pi_web_docker_host_write_volume "$extra_path" "$extra_path" false
|
||||
done
|
||||
}
|
||||
|
||||
pi_web_docker_host_write_compose_override() {
|
||||
target_file=$1
|
||||
host_profile=$2
|
||||
extra_paths=${3:-}
|
||||
control_path=${4:-}
|
||||
target_dir=$(dirname "$target_file")
|
||||
mkdir -p "$target_dir" || return 1
|
||||
PI_WEB_DOCKER_HOST_OVERRIDE_TEMP=$target_file.$$
|
||||
|
||||
case "$host_profile" in
|
||||
linux-native-docker) hostexec_mode=nsenter ;;
|
||||
mac-docker-desktop) hostexec_mode=disabled ;;
|
||||
*)
|
||||
printf '%s\n' "unsupported PI WEB Docker host profile: $host_profile" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
cat >"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <<EOF
|
||||
# Generated by PI WEB Docker host profile detection. Do not edit by hand.
|
||||
# Re-run the installer or docker/pi-web-docker --dev to refresh this file.
|
||||
|
||||
x-pi-web-host-volumes: &pi-web-host-volumes
|
||||
EOF
|
||||
|
||||
socket_source=${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-/var/run/docker.sock}
|
||||
pi_web_docker_host_write_volume "$socket_source" /var/run/docker.sock false
|
||||
|
||||
case "$host_profile" in
|
||||
linux-native-docker)
|
||||
pi_web_docker_host_write_existing_volume /home /home false
|
||||
pi_web_docker_host_write_existing_volume /srv /srv false
|
||||
pi_web_docker_host_write_existing_volume /opt /opt false
|
||||
pi_web_docker_host_write_volume / /host true
|
||||
;;
|
||||
mac-docker-desktop)
|
||||
pi_web_docker_host_write_existing_volume /Users /Users false
|
||||
pi_web_docker_host_write_existing_volume /Volumes /Volumes false
|
||||
pi_web_docker_host_write_existing_volume /private /private false
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! pi_web_docker_host_write_extra_volumes "$extra_paths"; then
|
||||
rm -f "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$control_path" ]; then
|
||||
if [ ! -e "$control_path" ]; then
|
||||
printf '%s\n' "PI WEB Docker control path does not exist: $control_path" >&2
|
||||
rm -f "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
|
||||
return 1
|
||||
fi
|
||||
pi_web_docker_host_write_volume "$control_path" "$control_path" false
|
||||
fi
|
||||
|
||||
cat >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <<EOF
|
||||
|
||||
services:
|
||||
sessiond:
|
||||
environment:
|
||||
HOSTEXEC_MODE: $hostexec_mode
|
||||
volumes: *pi-web-host-volumes
|
||||
|
||||
web:
|
||||
environment:
|
||||
HOSTEXEC_MODE: $hostexec_mode
|
||||
volumes: *pi-web-host-volumes
|
||||
EOF
|
||||
|
||||
mv "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" "$target_file"
|
||||
}
|
||||
|
||||
pi_web_docker_host_print_detection_failure() {
|
||||
printf '%s\n' "PI WEB Docker setup could not determine a supported host profile." >&2
|
||||
printf '%s\n' "" >&2
|
||||
printf '%s\n' "Detected:" >&2
|
||||
printf ' host OS: %s\n' "${PI_WEB_DETECTED_HOST_OS:-unknown}" >&2
|
||||
printf ' docker context: %s\n' "${PI_WEB_DETECTED_DOCKER_CONTEXT:-unknown}" >&2
|
||||
printf ' docker endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-unknown}" >&2
|
||||
printf ' DOCKER_HOST: %s\n' "${PI_WEB_DETECTED_DOCKER_HOST_ENV:-unset}" >&2
|
||||
printf ' effective endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" >&2
|
||||
printf ' docker socket source: %s\n' "${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-unknown}" >&2
|
||||
printf ' docker OS: %s\n' "${PI_WEB_DETECTED_DOCKER_OS:-unknown}" >&2
|
||||
printf '%s\n' "" >&2
|
||||
printf '%s\n' "Supported profiles:" >&2
|
||||
printf '%s\n' " - native Linux Docker Engine using /var/run/docker.sock" >&2
|
||||
printf '%s\n' " - Docker Desktop for Mac" >&2
|
||||
if [ -n "${PI_WEB_DOCKER_HOST_PROFILE_ERROR:-}" ]; then
|
||||
printf '%s\n' "" >&2
|
||||
printf 'Reason: %s\n' "$PI_WEB_DOCKER_HOST_PROFILE_ERROR" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
pi_web_docker_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose "$@"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
else
|
||||
printf '%s\n' "Docker Compose is required (docker compose plugin or docker-compose)" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
Executable
+169
@@ -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/*
|
||||
Executable
+832
@@ -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] <command> [args...]
|
||||
|
||||
Runtime/production mode is the default. Development mode must be selected
|
||||
explicitly with --dev.
|
||||
|
||||
Commands:
|
||||
install Run the production one-line/bootstrap installer
|
||||
start Start the PI WEB Docker stack
|
||||
stop Stop the PI WEB Docker stack without deleting data
|
||||
restart Restart web and sessiond
|
||||
restart-web Restart only the web service
|
||||
restart-sessiond Restart only the session daemon
|
||||
update Rebuild/update and recreate the Docker stack
|
||||
(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 <pi-web args...> Run the pi-web CLI in the web container
|
||||
|
||||
Update and restart commands launched inside a PI WEB Docker container start an
|
||||
independent helper container first, 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" != "<no value>" ] || die "could not detect this container's image; set PI_WEB_DOCKER_HELPER_IMAGE explicitly"
|
||||
printf '%s\n' "$image"
|
||||
}
|
||||
|
||||
control_env_file() {
|
||||
root=$1
|
||||
case "$(docker_mode)" in
|
||||
runtime) candidate=$root/.env ;;
|
||||
dev) candidate=$root/.pi-web/docker-compose-dev.generated.env ;;
|
||||
esac
|
||||
[ -f "$candidate" ] || die "generated $(docker_mode) Docker env not found at $candidate; run pi-web-docker $(mode_flag || true) status or start from the host first"
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
control_root_env_key() {
|
||||
case "$(docker_mode)" in
|
||||
runtime) printf '%s\n' PI_WEB_DOCKER_INSTALL_DIR ;;
|
||||
dev) printf '%s\n' PI_WEB_DOCKER_DEV_REPO_ROOT ;;
|
||||
esac
|
||||
}
|
||||
|
||||
required_env_file_value() {
|
||||
file=$1
|
||||
key=$2
|
||||
value=$(env_file_value "$file" "$key" 2>/dev/null || true)
|
||||
[ -n "$value" ] || die "generated Docker env $file must define $key for detached helpers"
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
cleanup_old_helpers() {
|
||||
root=${1:-}
|
||||
project_name=${2:-}
|
||||
base_filters="label=pi-web.docker-helper=true"
|
||||
if [ -n "$root" ] && [ -n "$project_name" ]; then
|
||||
ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter "label=pi-web.docker-helper.project=$project_name" --filter status=exited 2>/dev/null || true)
|
||||
elif [ -n "$root" ]; then
|
||||
ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter status=exited 2>/dev/null || true)
|
||||
else
|
||||
ids=$(docker ps -aq --filter "$base_filters" --filter status=exited 2>/dev/null || true)
|
||||
fi
|
||||
old_ids=$(docker ps -aq --filter label=pi-web.docker-control=true --filter status=exited 2>/dev/null || true)
|
||||
ids="$ids $old_ids"
|
||||
for id in $ids; do
|
||||
[ -n "$id" ] || continue
|
||||
docker rm "$id" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
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
|
||||
+141
-32
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Configure PI WEB — config files, paths, and session tools</title>
|
||||
<title>Configure PI WEB — config files, uploads, paths, and session tools</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Configure PI WEB config files, external path access, session daemon tools, plugins, shortcuts, uploads, and runtime environment variables."
|
||||
content="Configure PI WEB config files, external path access, manual upload defaults, session daemon tools, plugins, shortcuts, and runtime environment variables."
|
||||
/>
|
||||
<link rel="canonical" href="https://pi-web.dev/config" />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<meta property="og:title" content="Configure PI WEB" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables."
|
||||
content="Reference for PI WEB config files, path access allowlists, manual upload defaults, session daemon options, plugins, shortcuts, and environment variables."
|
||||
/>
|
||||
<meta property="og:url" content="https://pi-web.dev/config" />
|
||||
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
|
||||
@@ -23,7 +23,7 @@
|
||||
<meta name="twitter:title" content="Configure PI WEB" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables."
|
||||
content="Reference for PI WEB config files, path access allowlists, manual upload defaults, session daemon options, plugins, shortcuts, and environment variables."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
@@ -80,8 +80,8 @@
|
||||
<h1>Configure PI WEB where your agents work.</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -91,11 +91,13 @@
|
||||
<aside class="toc" aria-label="Config page contents">
|
||||
<strong>On this page</strong>
|
||||
<a href="#files">Config files</a>
|
||||
<a href="#deployment-paths">Deployment paths</a>
|
||||
<a href="#precedence">Precedence and reloads</a>
|
||||
<a href="#global-config">Global config</a>
|
||||
<a href="#project-config">Project config</a>
|
||||
<a href="#keys">Config matrix</a>
|
||||
<a href="#path-access">External path access</a>
|
||||
<a href="#manual-uploads">Manual uploads</a>
|
||||
<a href="#agent-runtime">Agent runtime</a>
|
||||
<a href="#session-tools">Session tools</a>
|
||||
<a href="#completion-tools">Completion tools</a>
|
||||
@@ -110,8 +112,20 @@
|
||||
<li><strong>Project config:</strong> <code><project>/.pi-web/config.json</code> for commit-able project settings.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's
|
||||
config by opening that machine directly or changing files on that machine.
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
|
||||
machine for config that affects work running there: agent runtime selection, session daemon tools,
|
||||
PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
|
||||
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
|
||||
that do not advertise selected-machine settings support report those settings as unavailable instead of
|
||||
silently falling back to the gateway.
|
||||
</p>
|
||||
<p>
|
||||
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the
|
||||
target machine and are managed by Pi (<code>pi install</code>, <code>pi remove</code>, <code>pi update</code>) or
|
||||
<strong>Settings → Pi packages</strong>. In a federated setup, <strong>Settings → Pi packages</strong>
|
||||
targets the currently selected machine. The PI WEB <code>plugins</code> config key only enables or
|
||||
disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not
|
||||
install, remove, or update Pi packages.
|
||||
</p>
|
||||
<p>
|
||||
If you installed services with a custom config path, rerun
|
||||
@@ -121,12 +135,32 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="deployment-paths">
|
||||
<h2>Reverse-proxy deployment paths</h2>
|
||||
<p>
|
||||
The deployment path is not a PI WEB config-file key or environment setting. The published client is
|
||||
portable: one build works at <code>/</code> and at canonical trailing-slash prefixes such as
|
||||
<code>/ai/</code> or <code>/test/ai/</code>.
|
||||
</p>
|
||||
<p>
|
||||
For a nested deployment, redirect the slashless prefix to the trailing-slash URL, strip the prefix
|
||||
before forwarding to PI WEB, and proxy authenticated HTTP and WebSocket traffic through the same
|
||||
location. Relative browser and PWA URLs then stay within that prefix. See the
|
||||
<a href="install#reverse-proxy-prefix">reverse proxy deployment example</a> for complete Nginx
|
||||
configuration.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="precedence">
|
||||
<h2>Precedence and reloads</h2>
|
||||
<p>Runtime values are resolved in this order:</p>
|
||||
<p>Machine-global runtime values are resolved in this order:</p>
|
||||
<div class="code-card">
|
||||
<pre><code>defaults → config file → environment overrides</code></pre>
|
||||
<pre><code>defaults → global config file → environment overrides</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Supported project-local settings are then applied for that project's workspaces. For upload defaults,
|
||||
<code><project>/.pi-web/config.json</code> overrides the global value.
|
||||
</p>
|
||||
<p>
|
||||
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
|
||||
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_AGENT_COMMAND</code>,
|
||||
@@ -135,11 +169,13 @@
|
||||
<code>PI_WEB_SUBSESSIONS</code>.
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>host</code> / <code>port</code>: restart the web/API service or process.</li>
|
||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon.</li>
|
||||
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li>
|
||||
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
|
||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
|
||||
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
|
||||
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
|
||||
<li><code>plugins</code>: reload the browser tab after changing plugin enablement.</li>
|
||||
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
|
||||
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
|
||||
<li>Pi package install/remove/update: not a PI WEB config key; after a mutation, type <code>/reload</code> in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.</li>
|
||||
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -147,9 +183,11 @@
|
||||
<section id="global-config">
|
||||
<h2>Global config example</h2>
|
||||
<p>
|
||||
<code>pi-web install</code> creates the initial file. You can also save settings from
|
||||
<strong>Settings → General</strong>, <strong>Settings → Plugins</strong>, <strong>Settings → Keyboard</strong>,
|
||||
and <strong>Settings → Session daemon</strong>.
|
||||
<code>pi-web install</code> creates the initial file. You can also save PI WEB config settings from
|
||||
<strong>Settings → General</strong>, <strong>Settings → PI WEB plugins</strong>,
|
||||
<strong>Settings → Keyboard</strong>, and <strong>Settings → Session daemon</strong>. Machine-affecting
|
||||
Settings fields target the selected machine; gateway host/port/allowed-hosts and keyboard shortcuts stay
|
||||
local. Pi package operations live separately under <strong>Settings → Pi packages</strong>.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -162,6 +200,9 @@
|
||||
"pathAccess": {
|
||||
"allowedPaths": ["~/SDKs", "/opt/reference"]
|
||||
},
|
||||
"uploads": {
|
||||
"defaultFolder": ".pi-web/uploads"
|
||||
},
|
||||
"maxUploadBytes": 67108864,
|
||||
"agent": {
|
||||
"command": "pi",
|
||||
@@ -186,8 +227,7 @@
|
||||
<h2>Project-local config</h2>
|
||||
<p>
|
||||
Project-local config lives at <code><project>/.pi-web/config.json</code>. Use it for settings that should
|
||||
follow a repository. When a project config defines <code>pathAccess</code>, PI WEB merges it after the
|
||||
global path list.
|
||||
follow a repository.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -198,13 +238,25 @@
|
||||
"version": 1,
|
||||
"pathAccess": {
|
||||
"allowedPaths": ["~/SDKs", "/opt/reference"]
|
||||
},
|
||||
"uploads": {
|
||||
"defaultFolder": "manual/uploads"
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Project-local <code>pathAccess.allowedPaths</code> entries must still be host-absolute or
|
||||
<code>~</code>-prefixed; relative roots are not supported. Plugins may own separate project files, such as
|
||||
<code>.pi-web/tasks.json</code> for the built-in Workspace Tasks plugin.
|
||||
Project-local <code>pathAccess.allowedPaths</code> entries are merged after the global list and deduplicated.
|
||||
Paths must still be host-absolute or <code>~</code>-prefixed; relative roots are not supported.
|
||||
</p>
|
||||
<p>
|
||||
Project-local <code>uploads.defaultFolder</code> overrides the global upload destination for workspaces in
|
||||
that project. Current PI WEB servers include this workspace-effective value on local and federated
|
||||
workspace responses; older remote servers may omit it and the browser falls back to the global/default
|
||||
upload folder.
|
||||
</p>
|
||||
<p>
|
||||
Plugins may own separate project files, such as <code>.pi-web/tasks.json</code> for the built-in Workspace
|
||||
Tasks plugin.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -213,7 +265,11 @@
|
||||
<p>
|
||||
Use this table as the quick reference for where a setting can live, which environment variable overrides
|
||||
it, and whether project-local config overrides or merges with global config. Rows with JSON key
|
||||
<code>—</code> are runtime-only environment variables, not config-file keys.
|
||||
<code>—</code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
|
||||
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
|
||||
<code>maxUploadBytes</code>, <code>agent</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
|
||||
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
|
||||
registry/tokens stay local.
|
||||
</p>
|
||||
<div class="table-scroll" role="region" aria-label="PI WEB configuration matrix" tabindex="0">
|
||||
<table class="config-matrix">
|
||||
@@ -261,13 +317,21 @@
|
||||
<td><strong>Merges:</strong> global roots first, then project roots; duplicates removed</td>
|
||||
<td>Next file request; refresh existing views if needed</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Manual file upload default folder</td>
|
||||
<td><code>uploads.defaultFolder</code></td>
|
||||
<td>—</td>
|
||||
<td>Global + project</td>
|
||||
<td><strong>Overrides:</strong> project value wins for workspaces in that project; otherwise global/default applies</td>
|
||||
<td>New Upload dialogs and direct drag/drop batches after config/workspace refresh</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Upload/body limit</td>
|
||||
<td><code>maxUploadBytes</code></td>
|
||||
<td><code>PI_WEB_MAX_UPLOAD_BYTES</code></td>
|
||||
<td>Global</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart web/API and session daemon</td>
|
||||
<td>Restart web/API and session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Agent CLI command</td>
|
||||
@@ -275,7 +339,7 @@
|
||||
<td><code>PI_WEB_AGENT_COMMAND</code></td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart session daemon; affects doctor/status/update checks</td>
|
||||
<td>Restart session daemon on that machine; affects doctor/status/update checks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Agent state directory</td>
|
||||
@@ -283,7 +347,7 @@
|
||||
<td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart session daemon; affects auth, models, settings, and sessions</td>
|
||||
<td>Restart session daemon on that machine; affects auth, models, settings, and sessions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Agent can spawn sessions</td>
|
||||
@@ -291,7 +355,7 @@
|
||||
<td><code>PI_WEB_SPAWN_SESSIONS</code></td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart session daemon</td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tracked subsessions (beta)</td>
|
||||
@@ -299,10 +363,10 @@
|
||||
<td><code>PI_WEB_SUBSESSIONS</code></td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally; also requires <code>spawnSessions</code></td>
|
||||
<td>Restart session daemon</td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Plugin enablement/settings</td>
|
||||
<td>PI WEB plugin enablement/settings</td>
|
||||
<td><code>plugins.<id>.enabled</code>, <code>plugins.<id>.settings</code></td>
|
||||
<td>—</td>
|
||||
<td>Global</td>
|
||||
@@ -437,12 +501,41 @@
|
||||
<code>realpath</code>, requires roots to be existing directories, and rejects symlink escapes outside the
|
||||
allowed roots.
|
||||
</p>
|
||||
<p>
|
||||
In <strong>Settings → General</strong>, external filesystem roots are saved on the selected machine.
|
||||
Gateway host, port, and allowed-hosts fields stay on the gateway config.
|
||||
</p>
|
||||
<div class="callout warning">
|
||||
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API
|
||||
file exposure outside a workspace. Add only roots you trust PI WEB to list and read through the browser UI.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="manual-uploads">
|
||||
<h2>Manual upload defaults</h2>
|
||||
<p>
|
||||
The Files panel can upload files by dropping them onto the panel or by using the toolbar
|
||||
<strong>Upload</strong> button. <code>uploads.defaultFolder</code> sets the workspace-effective default
|
||||
destination. The built-in default is <code>.pi-web/uploads</code>; a project-local value overrides the
|
||||
global value for workspaces in that project.
|
||||
</p>
|
||||
<p>
|
||||
The value must be a non-empty workspace-relative folder. PI WEB normalizes repeated separators and
|
||||
backslashes to <code>/</code>, and rejects absolute paths or <code>..</code> traversal. In the upload
|
||||
dialog only, clearing the destination field uploads that batch to the workspace root.
|
||||
</p>
|
||||
<p>
|
||||
Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is
|
||||
enabled by default, and overwrite is disabled by default. Browser-owned XHR progress is shown per
|
||||
batch/file, and conflicts or errors stay visible in the upload progress UI.
|
||||
</p>
|
||||
<p>
|
||||
For machine federation, Settings saves the global upload default on the selected machine. Current remote
|
||||
PI WEB servers also return workspace-effective upload defaults on workspace responses; older remote
|
||||
servers may omit them and the browser falls back to the global/default upload folder.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="agent-runtime">
|
||||
<h2>Agent runtime</h2>
|
||||
<p>
|
||||
@@ -480,7 +573,8 @@
|
||||
intentionally using the legacy Pi-compatible <code>PI_CODING_AGENT_SESSION_DIR</code> name.
|
||||
</p>
|
||||
<div class="callout warning">
|
||||
Restart the session daemon after changing agent settings. The web/API process can display the new config
|
||||
In <strong>Settings → Session daemon</strong>, agent settings are saved on the selected machine. Restart
|
||||
the session daemon on that machine after changing them. The web/API process can display the new config
|
||||
immediately, and status/plugin discovery may re-read it on later requests, but active session runtime
|
||||
ownership is intentionally long-lived.
|
||||
</div>
|
||||
@@ -503,8 +597,23 @@
|
||||
to be enabled.
|
||||
</p>
|
||||
<p>
|
||||
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop
|
||||
working, and inspect their transcripts. Restart the session daemon after changing this setting.
|
||||
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child
|
||||
stops working, and inspect their status and transcripts. Calling <code>spawn_subsession</code> returns
|
||||
immediately. The parent can continue independent work while treating every child whose result it needs
|
||||
as pending. Before producing work that depends on those results, the parent reaches a join point and
|
||||
yields until every required child has sent a completion notice.
|
||||
</p>
|
||||
<p>
|
||||
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current
|
||||
turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves
|
||||
one pending child; after processing it, the parent yields again if another required child is pending.
|
||||
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> provide
|
||||
on-demand status and transcript inspection for deliberate progress checks or recovery. Completion
|
||||
notifications, rather than polling these tools, are the normal synchronization mechanism.
|
||||
</p>
|
||||
<p>
|
||||
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
|
||||
session daemon on that machine after changing them.
|
||||
</p>
|
||||
<p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p>
|
||||
</section>
|
||||
|
||||
+33
-16
@@ -11,10 +11,18 @@ PI WEB uses two config files:
|
||||
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
|
||||
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
|
||||
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's config by opening that machine directly or changing files on that machine.
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: agent runtime selection, session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
|
||||
|
||||
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
|
||||
|
||||
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
|
||||
|
||||
## Reverse-proxy deployment paths
|
||||
|
||||
The deployment path is not a PI WEB config-file key or environment setting. The published client is portable: one build works at `/` and at canonical trailing-slash prefixes such as `/ai/` or `/test/ai/`.
|
||||
|
||||
For a nested deployment, redirect the slashless prefix to the trailing-slash URL, strip the prefix before forwarding to PI WEB, and proxy authenticated HTTP and WebSocket traffic through the same location. Relative browser and PWA URLs then stay within that prefix. See the [reverse proxy installation guide](https://pi-web.dev/install#reverse-proxy-prefix) for a complete Nginx example.
|
||||
|
||||
## Precedence and reloads
|
||||
|
||||
Machine-global runtime values are resolved as:
|
||||
@@ -29,12 +37,13 @@ Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALL
|
||||
|
||||
Process restarts depend on the key:
|
||||
|
||||
- `host` / `port`: restart the web/API service or process.
|
||||
- `maxUploadBytes`: restart both the web/API process and the session daemon.
|
||||
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon.
|
||||
- `host` / `port`: restart the gateway web/API service or process.
|
||||
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
|
||||
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon on that machine.
|
||||
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
|
||||
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
|
||||
- `plugins`: reload the browser tab after changing plugin enablement.
|
||||
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
|
||||
- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.
|
||||
- `shortcuts`: saved settings apply in the browser after config refresh/save.
|
||||
|
||||
## Global config example
|
||||
@@ -92,7 +101,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui
|
||||
|
||||
## Configuration matrix
|
||||
|
||||
Rows with JSON key `—` are runtime-only environment variables, not config-file keys.
|
||||
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
|
||||
|
||||
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -102,11 +111,11 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
|
||||
| Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI |
|
||||
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
|
||||
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
|
||||
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon |
|
||||
| Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon; affects doctor/status/update checks |
|
||||
| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon; affects auth, models, settings, and sessions |
|
||||
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon |
|
||||
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon |
|
||||
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
|
||||
| Agent CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks |
|
||||
| Agent state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, and sessions |
|
||||
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
|
||||
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
|
||||
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
|
||||
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
|
||||
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
|
||||
@@ -139,6 +148,8 @@ Accepted root forms:
|
||||
|
||||
When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots.
|
||||
|
||||
In **Settings → General**, external filesystem roots are saved on the selected machine. Gateway host, port, and allowed-hosts fields stay on the gateway config.
|
||||
|
||||
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace.
|
||||
|
||||
### Manual upload defaults
|
||||
@@ -162,9 +173,9 @@ The value must be a non-empty workspace-relative folder. PI WEB normalizes repea
|
||||
|
||||
Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth.
|
||||
|
||||
For machine federation, current remote PI WEB servers return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder.
|
||||
For machine federation, Settings saves the global upload default on the selected machine. Current remote PI WEB servers also return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder.
|
||||
|
||||
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`.
|
||||
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
|
||||
|
||||
### Agent runtime selection
|
||||
|
||||
@@ -187,7 +198,7 @@ Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAN
|
||||
|
||||
Session directory overrides are environment-only; use `PI_WEB_AGENT_SESSION_DIR` unless you are intentionally using the legacy Pi-compatible `PI_CODING_AGENT_SESSION_DIR` name.
|
||||
|
||||
Restart the session daemon after changing agent settings. The web/API process can display the new config immediately, and status/plugin discovery may re-read it on later requests, but active session runtime ownership is intentionally long-lived.
|
||||
In **Settings → Session daemon**, agent settings are saved on the selected machine. Restart the session daemon on that machine after changing them. The web/API process can display the new config immediately, and status/plugin discovery may re-read it on later requests, but active session runtime ownership is intentionally long-lived.
|
||||
|
||||
### Session daemon tools
|
||||
|
||||
@@ -195,11 +206,17 @@ Restart the session daemon after changing agent settings. The web/API process ca
|
||||
|
||||
`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled.
|
||||
|
||||
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts.
|
||||
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child stops working, and inspect their status and transcripts. Calling `spawn_subsession` returns immediately. The parent can continue independent work while treating every child whose result it needs as pending. Before producing work that depends on those results, the parent reaches a join point and yields until every required child has sent a completion notice.
|
||||
|
||||
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves one pending child; after processing it, the parent yields again if another required child is pending. `list_subsessions`, `check_subsession`, and `read_subsession` provide on-demand status and transcript inspection for deliberate progress checks or recovery. Completion notifications, rather than polling these tools, are the normal synchronization mechanism.
|
||||
|
||||
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
|
||||
|
||||
### Plugin config
|
||||
|
||||
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
|
||||
The `plugins` key is only for PI WEB browser plugin enablement/settings on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. In a federated setup, **Settings → PI WEB plugins** and **Settings → Pi packages** both target the currently selected machine, and each panel labels where changes will be saved or run.
|
||||
|
||||
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from that machine's `/pi-web-plugins/manifest.json` before the browser imports it. Settings lists discovered plugins from the selected machine, including disabled entries exposed by that machine.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+12
-8
@@ -131,8 +131,9 @@
|
||||
<h2>Tools are failing, node is not found, or Pi cannot find commands</h2>
|
||||
<p>
|
||||
The shell environment needs to be set up so login shells have the required PATH entries for PI WEB, Pi,
|
||||
and any tools your agents need. PI WEB services run commands through a non-interactive login shell, so
|
||||
an interactive terminal can work while services fail.
|
||||
and any tools your agents need. PI WEB services run commands through a non-interactive login shell owned
|
||||
by systemd or launchd, so an interactive terminal—or even a caller-invoked login shell—can work while the
|
||||
native service fails.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -156,14 +157,17 @@
|
||||
<article id="doctor-fails" class="faq-item">
|
||||
<h2>What does <code>pi-web doctor</code> check?</h2>
|
||||
<p>
|
||||
It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi
|
||||
Web binaries. It also prints installed and running PI WEB versions when available, reports optional ripgrep
|
||||
availability for faster all-file <code>@</code>-mention suggestions, uses a bounded filesystem fallback when
|
||||
ripgrep is unavailable, and reports user service lingering when relevant for server-style installs.
|
||||
It keeps two kinds of checks separate. General login-shell readiness covers Node 22+, npm, Pi, and optional
|
||||
ripgrep. Native-service diagnostics validate only the exact prerequisites of the selected service plan in
|
||||
the real systemd user-manager or launchd <code>gui/<uid></code> context. Development installs follow their
|
||||
installed checkout plan; production checks are clearly labelled prospective when the installed executable
|
||||
strategy cannot be reconstructed safely.
|
||||
</p>
|
||||
<p>
|
||||
If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and
|
||||
move the setup earlier in your shell startup chain.
|
||||
Missing plan requirements fail doctor and include login-file guidance. Manager, timeout, malformed-output,
|
||||
and cleanup failures are reported as probe infrastructure problems rather than being mislabeled as PATH
|
||||
drift. On unsupported/manual-only platforms, native-service drift checks are skipped. Doctor also prints
|
||||
installed and running PI WEB versions and reports systemd lingering when relevant.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
|
||||
+3
-3
@@ -138,8 +138,8 @@
|
||||
# laptop, phone, tablet — same live sessions
|
||||
|
||||
<span class="prompt">$</span> pi-web doctor
|
||||
✓ login shell can find node >= 22
|
||||
✓ native service shell can find pi
|
||||
✓ caller login shell can find node >= 22
|
||||
✓ native-service plan requirements pass in manager context
|
||||
✓ ready for persistent agent work</code></pre>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -251,7 +251,7 @@
|
||||
<p>
|
||||
Your device is replaceable. The sessions are not. Move between laptop, phone, tablet, and desktop
|
||||
without moving the development environment. Under the hood, PI WEB coordinates the running sessions,
|
||||
files, terminals, and remote machines like a browser-based control plane.
|
||||
files, terminals, Pi package management, and remote machines like a browser-based control plane.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
|
||||
+83
-4
@@ -95,6 +95,7 @@
|
||||
<a href="#pi-package">Install through Pi</a>
|
||||
<a href="#manual-run">WSL / manual run</a>
|
||||
<a href="#remote-access">Remote access</a>
|
||||
<a href="#reverse-proxy-prefix">Reverse proxy prefixes</a>
|
||||
<a href="#federated-machines">Federated machines</a>
|
||||
<a href="#manage-services">Manage services</a>
|
||||
<a href="#configure">Configure</a>
|
||||
@@ -112,8 +113,11 @@
|
||||
</ul>
|
||||
<div class="callout warning">
|
||||
<strong>Important PATH detail:</strong>
|
||||
PI WEB services run through your login shell with <code>-lc</code>. Setup that only lives in interactive shell
|
||||
files or prompt hooks may not be visible to services. Run <code>pi-web doctor</code> after installing.
|
||||
PI WEB services run through a non-interactive login shell with <code>-lc</code>. Setup that only lives in
|
||||
interactive shell files or prompt hooks may not be visible to the systemd or launchd manager. The installer
|
||||
probes the safely verifiable requirements of the exact candidate plan in that manager context before changing
|
||||
config or replacing services. Arbitrary configured command overrides are preserved but not executed by
|
||||
preflight; run <code>pi-web doctor</code> later to repeat plan-specific diagnostics.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -134,12 +138,13 @@
|
||||
<span class="prompt">$</span> pi-web doctor</code></pre>
|
||||
</div>
|
||||
<p>Then open <a href="http://127.0.0.1:8504">http://127.0.0.1:8504</a>.</p>
|
||||
<p>If preflight fails, no config or existing services are changed. Follow the detected shell guidance: zsh services read <code>~/.zprofile</code>, not interactive-only <code>~/.zshrc</code>; bash uses <code>~/.bash_profile</code> or <code>~/.profile</code>.</p>
|
||||
<p>On Linux servers, also consider <code>sudo loginctl enable-linger "$USER"</code> so user services survive logout/reboot.</p>
|
||||
</section>
|
||||
|
||||
<section id="one-line">
|
||||
<h2>One-line install</h2>
|
||||
<p>If you prefer a curl pipe, use the repository installer:</p>
|
||||
<p>If you prefer a curl pipe for the native user-service install, use the repository installer. This path still requires Node.js, npm, and Pi Coding Agent on the host.</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>One-liner</strong>
|
||||
@@ -220,6 +225,79 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="reverse-proxy-prefix">
|
||||
<h2>Reverse proxy root and path-prefix deployments</h2>
|
||||
<p>
|
||||
The published PI WEB client is deployment-independent. The same package works at the origin root
|
||||
(<code>/</code>) or at canonical nested prefixes such as <code>/ai/</code> and <code>/test/ai/</code>;
|
||||
no prefix-specific rebuild or PI WEB configuration is needed.
|
||||
</p>
|
||||
<p>
|
||||
For a root deployment, proxy <code>/</code> directly to <code>http://127.0.0.1:8504</code> without
|
||||
rewriting the path. For a nested deployment:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Redirect the slashless prefix, such as <code>/ai</code>, to <code>/ai/</code>. The browser uses the trailing-slash document URL as the application base.</li>
|
||||
<li>Strip the prefix before forwarding. PI WEB continues to serve root paths on its localhost listener.</li>
|
||||
<li>Apply authentication to the whole served <code>/ai/</code> application and preserve required authentication headers and cookies.</li>
|
||||
<li>Forward WebSocket upgrades through the same location as HTTP, API, image, PWA, and plugin traffic.</li>
|
||||
</ol>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Nginx path-prefix proxy</strong>
|
||||
<button class="copy-button" data-copy="#nginx-prefix-proxy">Copy</button>
|
||||
</div>
|
||||
<pre id="nginx-prefix-proxy"><code><span class="comment"># http context</span>
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name pi.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/pi.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/pi.example.com/privkey.pem;
|
||||
|
||||
auth_basic "PI WEB";
|
||||
auth_basic_user_file /etc/nginx/pi-web.htpasswd;
|
||||
|
||||
location = /ai {
|
||||
return 308 /ai/$is_args$args;
|
||||
}
|
||||
|
||||
location ^~ /ai/ {
|
||||
<span class="comment"># The trailing slash strips /ai/ before forwarding.</span>
|
||||
proxy_pass http://127.0.0.1:8504/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Use the same pattern for <code>/test/ai/</code> by changing both Nginx locations. If your proxy uses
|
||||
bearer tokens, SSO, or another authentication mechanism, keep that policy on the prefixed application
|
||||
location and continue forwarding the headers or cookies it requires; the slashless redirect serves no
|
||||
PI WEB content. Do not create unprotected exceptions for <code>/api/</code> or
|
||||
<code>/pi-web-plugins/</code>.
|
||||
</p>
|
||||
<p>
|
||||
Once the proxy follows this contract, relative client assets, images, PWA assets, API calls, local and
|
||||
federated plugins, and WebSocket URLs stay inside the prefix. Installed PWA <code>start_url</code> and
|
||||
scope stay inside it as well.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="federated-machines">
|
||||
<h2>Federated machines</h2>
|
||||
<p>
|
||||
@@ -281,7 +359,8 @@
|
||||
</div>
|
||||
<p>
|
||||
Use <strong>Settings → General</strong> for host, port, and external filesystem roots; <strong>Settings → Session daemon</strong>
|
||||
for agent-spawn tools; <strong>Settings → Plugins</strong> for plugin enablement; and <strong>Settings → Keyboard</strong>
|
||||
for agent-spawn tools; <strong>Settings → Pi packages</strong> for Pi package install/remove/update;
|
||||
<strong>Settings → PI WEB plugins</strong> for browser plugin enablement; and <strong>Settings → Keyboard</strong>
|
||||
for shortcut overrides.
|
||||
</p>
|
||||
<div class="callout">
|
||||
|
||||
+12
-8
@@ -6,7 +6,7 @@
|
||||
<title>PI WEB fleet — remote Pi web UI machines</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Connect trusted PI WEB runtimes through machine federation so one web UI can supervise local and remote projects, sessions, files, git state, terminals, and plugins."
|
||||
content="Connect trusted PI WEB runtimes through machine federation so one web UI can supervise local and remote projects, sessions, files, git state, terminals, Pi packages, and plugins."
|
||||
/>
|
||||
<link rel="canonical" href="https://pi-web.dev/machines" />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -81,7 +81,7 @@
|
||||
<p>
|
||||
Most PI WEB setups only need one runtime. When you do have more than one, machine federation lets the PI WEB
|
||||
instance you opened act as a gateway to other trusted runtimes while each machine keeps its own repositories,
|
||||
credentials, sessions, and plugins.
|
||||
credentials, sessions, Pi package settings, and plugins.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -111,7 +111,7 @@
|
||||
<p>
|
||||
After registration, the browser keeps talking to the current PI WEB origin. The gateway contacts the
|
||||
selected remote PI WEB server and routes that machine's projects, workspaces, sessions, files, git state,
|
||||
activity, and terminals to the browser UI.
|
||||
activity, terminals, and Pi package-management operations to the browser UI.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -125,7 +125,7 @@ PI WEB gateway you opened
|
||||
│
|
||||
├─ [ Remote PI WEB runtime A ]
|
||||
│ ↓ selected machine
|
||||
│ projects, workspaces, sessions, terminals, plugins
|
||||
│ projects, workspaces, sessions, terminals, Pi packages, plugins
|
||||
│
|
||||
└─ [ Remote PI WEB runtime B ]</code></pre>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@ PI WEB gateway you opened
|
||||
<article class="card">
|
||||
<div class="card-icon">⌁</div>
|
||||
<h3>Local ownership</h3>
|
||||
<p>Each target machine keeps its own Pi auth, sessions, worktrees, terminal state, and plugins.</p>
|
||||
<p>Each target machine keeps its own Pi auth, sessions, worktrees, terminal state, Pi package settings, and plugins.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -174,7 +174,9 @@ PI WEB gateway you opened
|
||||
<p>
|
||||
Prefer a private path such as NetBird, Tailscale, WireGuard, private LAN, SSH tunnel, or an authenticated reverse
|
||||
proxy. If the remote is behind a path prefix, include that prefix in the machine URL, for example
|
||||
<code>https://devbox.example.test/pi-web</code>.
|
||||
<code>https://devbox.example.test/pi-web</code>. The machine registry normalizes the trailing slash; when
|
||||
opening that deployment directly in a browser, use its canonical <code>https://devbox.example.test/pi-web/</code>
|
||||
URL and configure the proxy to redirect the slashless form.
|
||||
</p>
|
||||
<div class="callout danger">
|
||||
Do not expose PI WEB directly to the public internet. Register machines only over trusted network paths
|
||||
@@ -213,6 +215,7 @@ PI WEB gateway you opened
|
||||
<li>Pi sessions, transcripts, prompts, model controls, and commands.</li>
|
||||
<li>Activity indicators and realtime updates.</li>
|
||||
<li>Terminals and terminal command runs.</li>
|
||||
<li>Pi package listing, install, remove, and update from <strong>Settings → Pi packages</strong> when supported by the target PI WEB runtime.</li>
|
||||
<li>Remote plugins from the selected machine.</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -220,8 +223,9 @@ PI WEB gateway you opened
|
||||
<section id="auth-credentials">
|
||||
<h2>Credentials stay on the target machine</h2>
|
||||
<p>
|
||||
Model-provider credentials, Pi configuration, OAuth state, repositories, and active session runtimes stay
|
||||
on the selected target machine. The gateway does not copy them into its own Pi configuration.
|
||||
Model-provider credentials, Pi configuration, Pi package-manager settings, OAuth state, repositories, and
|
||||
active session runtimes stay on the selected target machine. The gateway does not copy them into its own
|
||||
Pi configuration.
|
||||
</p>
|
||||
<ul>
|
||||
<li>API-key provider configuration can be proxied through the gateway.</li>
|
||||
|
||||
+74
-15
@@ -90,10 +90,11 @@
|
||||
<aside class="toc" aria-label="Plugin page contents">
|
||||
<strong>On this page</strong>
|
||||
<a href="#extend">What can be extended</a>
|
||||
<a href="#packages-vs-plugins">Pi packages vs PI WEB plugins</a>
|
||||
<a href="#ask-ai">What to ask AI to build</a>
|
||||
<a href="#example">Canonical example</a>
|
||||
<a href="#built-in-plugins">Built-in plugins</a>
|
||||
<a href="#manage-plugins">Manage plugins</a>
|
||||
<a href="#manage-plugins">Manage PI WEB plugins</a>
|
||||
<a href="#remote-machine-plugins">Remote machine plugins</a>
|
||||
<a href="#production">Production usage</a>
|
||||
<a href="#agent-docs">AI-friendly docs</a>
|
||||
@@ -125,6 +126,44 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="packages-vs-plugins">
|
||||
<h2>Pi packages vs PI WEB plugins</h2>
|
||||
<p>
|
||||
<strong>Pi packages</strong> are packages managed by Pi (<code>pi install</code>, <code>pi remove</code>,
|
||||
<code>pi update</code>). A Pi package can provide extensions, skills, prompt templates, themes,
|
||||
context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
|
||||
</p>
|
||||
<p>
|
||||
<strong>PI WEB plugins</strong> are browser-side UI modules discovered from bundled, local, dev, and
|
||||
installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing,
|
||||
removing, or updating a Pi package is a Pi package-manager task.
|
||||
</p>
|
||||
<p>
|
||||
Use <strong>Settings → Pi packages</strong> to view configured Pi packages or install/remove/update a
|
||||
package. Enter only the package source, such as <code>npm:@scope/package</code>, a git/URL source, or a
|
||||
local path. PI WEB uses Pi's default package location, equivalent to <code>pi install <source></code>,
|
||||
and does not ask for an install location.
|
||||
</p>
|
||||
<p>
|
||||
When machine federation is enabled, <strong>Settings → Pi packages</strong> targets the currently selected
|
||||
machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote
|
||||
PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes,
|
||||
PI WEB reports the package management operation as unsupported or unavailable instead of silently falling
|
||||
back to the gateway.
|
||||
</p>
|
||||
<p>
|
||||
Use <strong>Settings → PI WEB plugins</strong> to enable or disable discovered PI WEB browser plugins
|
||||
before the browser imports them. In a federated setup, this plugin enablement surface targets the
|
||||
currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB
|
||||
server does not advertise selected-machine settings support, PI WEB reports the plugin settings as
|
||||
unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or
|
||||
updating a Pi package, type <code>/reload</code> in each idle PI WEB session on the target machine to
|
||||
refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system
|
||||
prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed
|
||||
PI WEB browser plugins. A routine session daemon restart is not required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="ask-ai">
|
||||
<h2>What to ask AI to build</h2>
|
||||
<p>
|
||||
@@ -204,18 +243,21 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
plugins appear in <code>/pi-web-plugins/manifest.json</code>.
|
||||
</p>
|
||||
<p>
|
||||
Built-in plugins can be managed from <strong>Settings → Plugins</strong> or with the top-level
|
||||
Built-in plugins can be managed from <strong>Settings → PI WEB plugins</strong> or with the top-level
|
||||
<code>plugins</code> config key.
|
||||
</p>
|
||||
|
||||
<h3>Updates</h3>
|
||||
<p>
|
||||
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
||||
restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
|
||||
selected machine's plugin copy when machine federation is active.
|
||||
restart, and installed-service guidance, plus a <strong>Check for PI WEB Updates</strong> action. It is
|
||||
built into PI WEB, enabled by default, and uses the selected machine's plugin copy when machine
|
||||
federation is active.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Plugin id: <code>updates</code></li>
|
||||
<li>Selected-machine status refreshes every 15 minutes while a browser tab is connected.</li>
|
||||
<li>Automatic npm release lookups are cached for six hours; the action bypasses the caches and checks immediately.</li>
|
||||
</ul>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -258,11 +300,11 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "docker.start",
|
||||
"title": "Start Docker",
|
||||
"group": "Docker",
|
||||
"description": "Start the local Docker Compose environment.",
|
||||
"command": "./docker/scripts/docker-compose-dev up -d"
|
||||
"id": "app.start",
|
||||
"title": "Start app",
|
||||
"group": "Development",
|
||||
"description": "Start the local development server.",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"id": "db.reset",
|
||||
@@ -285,12 +327,20 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
</section>
|
||||
|
||||
<section id="manage-plugins">
|
||||
<h2>Manage plugins</h2>
|
||||
<h2>Manage PI WEB plugins</h2>
|
||||
<p>
|
||||
Open <strong>Settings → Plugins</strong> to review discovered bundled, local, dev, and Pi package plugins
|
||||
for the PI WEB gateway you opened. PI WEB can disable any discovered plugin before the browser imports
|
||||
it. Core app contributions such as the command palette, base workspace tools, and themes are not managed
|
||||
through this plugin list.
|
||||
Open <strong>Settings → PI WEB plugins</strong> to review discovered bundled, local, dev, and Pi package
|
||||
plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin
|
||||
list; when a remote machine is selected, the list comes from that remote PI WEB server and includes
|
||||
disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before
|
||||
the browser imports it. Core app contributions such as the command palette, base workspace tools, and
|
||||
themes are not managed through this plugin list.
|
||||
</p>
|
||||
<p>
|
||||
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may
|
||||
provide plugins or other Pi resources, use <strong>Settings → Pi packages</strong>. In a federated setup,
|
||||
both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement
|
||||
still writes the PI WEB <code>plugins</code> config key rather than changing Pi package-manager settings.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -336,13 +386,22 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
</ul>
|
||||
<p>
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
|
||||
one, open that machine directly or update its config file.
|
||||
one, select that machine and use <strong>Settings → PI WEB plugins</strong> when the remote server exposes
|
||||
selected-machine settings, or open that machine directly/update its config file.
|
||||
</p>
|
||||
<p>
|
||||
Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose
|
||||
UI should come from the selected PI WEB instance; on remote machines, the gateway copy is hidden unless
|
||||
the remote machine exposes its own copy.
|
||||
</p>
|
||||
<p>
|
||||
Current PI WEB manifests publish leading application-root module references. The browser keeps them
|
||||
inside the current application base, so local and federated plugins follow root or nested reverse-proxy
|
||||
deployments without a prefix-specific build while remaining compatible with existing gateways.
|
||||
Federated gateways also accept manifest-relative references such as
|
||||
<code>./<plugin-id>/plugin.js</code> and legacy plugin-root-relative references such as
|
||||
<code>nested/plugin.js</code> from remote machines.
|
||||
</p>
|
||||
<p>
|
||||
For portable plugin assets, prefer URLs relative to the plugin module, such as
|
||||
<code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs,
|
||||
|
||||
+46
-18
@@ -13,6 +13,18 @@ Plugins can currently:
|
||||
|
||||
They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed.
|
||||
|
||||
## Pi packages vs PI WEB plugins
|
||||
|
||||
**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
|
||||
|
||||
**PI WEB plugins** are browser-side PI WEB UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a Pi package-manager task.
|
||||
|
||||
Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install <source>`, and does not ask for an install location.
|
||||
|
||||
When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway.
|
||||
|
||||
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
|
||||
|
||||
## Trust model
|
||||
|
||||
Plugins run as JavaScript in the browser app. Treat them as trusted code:
|
||||
@@ -140,7 +152,7 @@ When [machine federation](https://pi-web.dev/machines) is enabled, PI WEB also l
|
||||
- remote theme contributions are ignored for now because themes are app-wide;
|
||||
- mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
|
||||
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, select that machine and use **Settings → PI WEB plugins** when the remote server exposes selected-machine settings, or open that machine directly/update its config file.
|
||||
|
||||
Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine:
|
||||
|
||||
@@ -155,9 +167,11 @@ const url = new URL("./asset.json", import.meta.url);
|
||||
|
||||
If a remote plugin constructs absolute asset URLs, it should use the `pluginId` from `activate()` because PI WEB gives remote plugins a gateway-scoped runtime id. Hard-coded `/pi-web-plugins/<original-id>/...` URLs may point at the gateway instead of the remote machine.
|
||||
|
||||
## Manage plugins
|
||||
## Manage PI WEB plugins
|
||||
|
||||
Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
|
||||
Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
|
||||
|
||||
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings.
|
||||
|
||||
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
|
||||
|
||||
@@ -183,14 +197,16 @@ After changing plugin enablement, reload the PI WEB browser tab. Already-loaded
|
||||
|
||||
PI WEB ships core, discoverable plugins in the main `@jmfederico/pi-web` npm package. No separate `pi install` step is required: update PI WEB, reload the browser tab, and the bundled plugins appear in `/pi-web-plugins/manifest.json`.
|
||||
|
||||
Built-in plugins can be managed from **Settings → Plugins** or with the top-level `plugins` config key.
|
||||
Built-in plugins can be managed from **Settings → PI WEB plugins** or with the top-level `plugins` config key.
|
||||
|
||||
### Updates
|
||||
|
||||
**Plugin id:** `updates`
|
||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
|
||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance, plus a **Check for PI WEB Updates** action for the selected machine.
|
||||
|
||||
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
|
||||
While a browser tab is connected, PI WEB refreshes the selected machine's status every 15 minutes. npm release lookups are cached on that machine for six hours, so the automatic refresh normally contacts npm at most once in that window. Run **Check for PI WEB Updates** from the action palette to bypass both caches and check immediately. Operator settings that skip remote version checks, such as `PI_WEB_OFFLINE`, are still respected.
|
||||
|
||||
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab and action only appear for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -206,7 +222,7 @@ Updates is enabled by default. It declares `machineSpecific: true` so the gatewa
|
||||
**Config file:** `.pi-web/tasks.json`
|
||||
**What it does:** adds a **Tasks** workspace tab for running configured shell commands in dedicated PI WEB terminals.
|
||||
|
||||
Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → Plugins** or set:
|
||||
Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → PI WEB plugins** or set:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -223,11 +239,11 @@ Configure workspace tasks in `.pi-web/tasks.json`:
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "docker.start",
|
||||
"title": "Start Docker",
|
||||
"group": "Docker",
|
||||
"description": "Start the local Docker Compose environment.",
|
||||
"command": "./docker/scripts/docker-compose-dev up -d"
|
||||
"id": "app.start",
|
||||
"title": "Start app",
|
||||
"group": "Development",
|
||||
"description": "Start the local development server.",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"id": "db.reset",
|
||||
@@ -273,7 +289,7 @@ PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources:
|
||||
|
||||
Entries may be real directories or symlinks. This is the recommended development workflow.
|
||||
|
||||
3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped.
|
||||
3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped. Installing/removing/updating Pi packages is done from **Settings → Pi packages** (or Pi's package manager), not from the PI WEB plugin enable/disable list.
|
||||
|
||||
Remote machines expose their own manifests through the gateway at `/api/machines/<machine-id>/pi-web-plugins/manifest.json`. Those plugin modules are rewritten to gateway-scoped asset URLs and registered under machine-scoped runtime ids so duplicate plugin ids on different machines do not collide.
|
||||
|
||||
@@ -309,7 +325,7 @@ Rules:
|
||||
|
||||
### Manifest and assets
|
||||
|
||||
The manifest contains each discovered plugin module:
|
||||
The manifest contains each discovered plugin module. Current PI WEB releases emit `module` as a leading application-root reference:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -325,15 +341,25 @@ The manifest contains each discovered plugin module:
|
||||
}
|
||||
```
|
||||
|
||||
The browser maps leading application-root references into the current application base, so the same manifest works at the origin root or under a reverse-proxy path prefix. Keeping this output format also lets gateways from existing PI WEB releases consume plugins from an upgraded remote machine. For compatibility, federated gateways additionally accept explicit manifest-relative references such as `./my-plugin/pi-web-plugin.js` and legacy plugin-root-relative references such as `nested/pi-web-plugin.js`; all accepted forms are rewritten to deployment-portable, gateway-relative references.
|
||||
|
||||
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear.
|
||||
|
||||
A plugin can fetch its own static assets with URLs under:
|
||||
At an origin-root deployment, a plugin's static assets are available under:
|
||||
|
||||
```text
|
||||
/pi-web-plugins/<plugin-id>/<path-inside-plugin-root>
|
||||
```
|
||||
|
||||
PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream.
|
||||
Prefer module-relative asset URLs so they also work for remote machine plugins. For example, a built plugin module can reference an SVG shipped beside it:
|
||||
|
||||
```js
|
||||
const iconUrl = new URL("./assets/icon.svg", import.meta.url);
|
||||
```
|
||||
|
||||
The final installed plugin package must contain `assets/icon.svg` at that path relative to the final built module. PI WEB serves files that already exist in the package; it does not copy a source `public/` directory or apply Vite-style public-directory semantics. Configure the plugin build and package contents to emit or copy the asset into its final module-relative location.
|
||||
|
||||
PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, HTML, and SVG files get appropriate content types; unknown file types are served as octet-stream.
|
||||
|
||||
## Plugin module shape
|
||||
|
||||
@@ -452,6 +478,7 @@ interface PluginRuntimeContext {
|
||||
openTerminal: (options?: { terminalId?: string }) => void;
|
||||
refreshFiles: () => void | Promise<void>;
|
||||
refreshGit: () => void | Promise<void>;
|
||||
checkForPiWebUpdates?: () => void | Promise<void>;
|
||||
startSession: () => void | Promise<void>;
|
||||
archiveSession: () => void | Promise<void>;
|
||||
stopActiveWork: () => void | Promise<void>;
|
||||
@@ -466,6 +493,7 @@ Notes:
|
||||
- `enabled` is evaluated when the action palette asks for actions.
|
||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
||||
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
|
||||
- `checkForPiWebUpdates()` forces a fresh update check on the selected machine and refreshes `state.piWebStatus`. It is optional so plugins remain compatible with older PI WEB hosts.
|
||||
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear.
|
||||
|
||||
### Prompt editor API
|
||||
@@ -746,7 +774,7 @@ Labels should use the same helper through a plugin-owned cache because `items()`
|
||||
const envCache = new Map();
|
||||
|
||||
function envKey(machine, workspace) {
|
||||
return `${machine.id}:${workspace.id}:docker/development.be-go.local.env`;
|
||||
return `${machine.id}:${workspace.id}:.env.local`;
|
||||
}
|
||||
|
||||
function loadEnvLabel(context) {
|
||||
@@ -756,7 +784,7 @@ function loadEnvLabel(context) {
|
||||
|
||||
const pending = { status: "loading", label: undefined };
|
||||
envCache.set(key, pending);
|
||||
context.files.readFile("docker/development.be-go.local.env")
|
||||
context.files.readFile(".env.local")
|
||||
.then((file) => {
|
||||
pending.status = "ready";
|
||||
pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1];
|
||||
|
||||
Generated
+1370
-1424
File diff suppressed because it is too large
Load Diff
+35
-31
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202606.6",
|
||||
"version": "1.202607.0",
|
||||
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
|
||||
"license": "MIT",
|
||||
"author": "Federico Jaramillo Martinez",
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.testSupport.*",
|
||||
"install.sh",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
@@ -29,18 +30,20 @@
|
||||
"dev:server": "npm run dev:web",
|
||||
"dev:client": "vite --host 0.0.0.0",
|
||||
"dev:plugins": "node scripts/build-plugins.mjs --watch",
|
||||
"build": "tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build",
|
||||
"build": "npm run clean && tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build",
|
||||
"build:plugin-api": "tsc -p tsconfig.plugin-api.json",
|
||||
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
|
||||
"capture:screenshots": "node scripts/capture-screenshots.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:cached": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/pi-web/typecheck.tsbuildinfo",
|
||||
"knip": "knip",
|
||||
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"verify": "npm run typecheck && npm run lint && npm run knip && npm test",
|
||||
"verify:staged": "node scripts/verify-staged.mjs",
|
||||
"start": "tsx src/server/index.ts",
|
||||
"start:sessiond": "tsx src/server/sessiond.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"prepack": "npm run build",
|
||||
"pack:dry": "npm pack --dry-run",
|
||||
"prepublishOnly": "npm run verify",
|
||||
@@ -51,7 +54,7 @@
|
||||
"changelog:status": "changeset status"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
@@ -60,38 +63,39 @@
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.42.1",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@fastify/compress": "^9.0.0",
|
||||
"@fastify/static": "^9.3.0",
|
||||
"@fastify/websocket": "^11.3.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"diff": "^8.0.4",
|
||||
"fastify": "^5.6.1",
|
||||
"lit": "^3.3.1",
|
||||
"marked": "^18.0.3",
|
||||
"diff": "^9.0.0",
|
||||
"fastify": "^5.10.0",
|
||||
"lit": "^3.3.3",
|
||||
"marked": "^18.0.6",
|
||||
"node-pty": "^1.1.0",
|
||||
"typebox": "1.1.38",
|
||||
"ws": "^8.20.1"
|
||||
"typebox": "1.3.6",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@earendil-works/pi-agent-core": "^0.79.1",
|
||||
"@earendil-works/pi-ai": "^0.79.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.79.1",
|
||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"@earendil-works/pi-coding-agent": "^0.80.6",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^10.3.0",
|
||||
"globals": "^17.6.0",
|
||||
"knip": "^6.16.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.5"
|
||||
"eslint": "^10.6.0",
|
||||
"globals": "^17.7.0",
|
||||
"knip": "^6.25.0",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -109,9 +113,9 @@
|
||||
"homepage": "https://pi-web.dev/",
|
||||
"packageManager": "[email protected]",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-agent-core": ">=0.78.0 <1",
|
||||
"@earendil-works/pi-ai": ">=0.78.0 <1",
|
||||
"@earendil-works/pi-coding-agent": ">=0.78.0 <1"
|
||||
"@earendil-works/pi-agent-core": ">=0.80.0 <1",
|
||||
"@earendil-works/pi-ai": ">=0.80.0 <1",
|
||||
"@earendil-works/pi-coding-agent": ">=0.80.0 <1"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { html, svg } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntimeContext } from "@jmfederico/pi-web/plugin-api";
|
||||
import plugin from "./pi-web-plugin.js";
|
||||
|
||||
describe("Updates plugin actions", () => {
|
||||
it("forces an update check through the host runtime context", async () => {
|
||||
const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check");
|
||||
if (action === undefined) throw new Error("Expected update check action");
|
||||
const checkForPiWebUpdates = vi.fn(() => Promise.resolve());
|
||||
const context = runtimeContext({ checkForPiWebUpdates });
|
||||
|
||||
expect(action.enabled?.(context)).toBe(true);
|
||||
await action.run(context);
|
||||
|
||||
expect(checkForPiWebUpdates).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables the action on older hosts without the update-check helper", () => {
|
||||
const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check");
|
||||
if (action === undefined) throw new Error("Expected update check action");
|
||||
const context = runtimeContext();
|
||||
|
||||
expect(action.enabled?.(context)).toBe(false);
|
||||
expect(action.disabledReason?.(context)).toContain("newer PI WEB gateway");
|
||||
});
|
||||
});
|
||||
|
||||
function runtimeContext(patch: Partial<PluginRuntimeContext> = {}): PluginRuntimeContext {
|
||||
const noop = () => undefined;
|
||||
return {
|
||||
state: {},
|
||||
prompt: { insertText: noop, getText: () => "", getSelection: () => null },
|
||||
openActionPalette: noop,
|
||||
focusPrompt: noop,
|
||||
addProject: noop,
|
||||
configureAuth: noop,
|
||||
logoutAuth: noop,
|
||||
openThemePicker: noop,
|
||||
selectMainView: noop,
|
||||
selectWorkspaceTool: noop,
|
||||
openTerminal: noop,
|
||||
refreshFiles: noop,
|
||||
refreshGit: noop,
|
||||
refreshAppData: noop,
|
||||
reloadPage: noop,
|
||||
startSession: noop,
|
||||
archiveSession: noop,
|
||||
stopActiveWork: noop,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebPlugin, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
|
||||
import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor } from "./updatesLogic.js";
|
||||
import { additionalCommands, fallbackDockerStatus, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor, type UpdatesRuntimeHint } from "./updatesLogic.js";
|
||||
|
||||
function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void {
|
||||
void terminal.runCommand({
|
||||
@@ -48,6 +48,17 @@ function renderCommand(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal |
|
||||
`;
|
||||
}
|
||||
|
||||
function updatesRuntimeHintFromModuleUrl(moduleUrl: string): UpdatesRuntimeHint {
|
||||
try {
|
||||
const dockerMode = new URL(moduleUrl).searchParams.get("piWebDockerMode");
|
||||
return dockerMode === "runtime" || dockerMode === "dev" ? { dockerMode } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeHint = updatesRuntimeHintFromModuleUrl(import.meta.url);
|
||||
|
||||
function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined {
|
||||
const recommended = recommendedCommand(status);
|
||||
const additional = additionalCommands(status, recommended);
|
||||
@@ -71,7 +82,7 @@ function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal
|
||||
}
|
||||
|
||||
function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
|
||||
const status = statusFor(state);
|
||||
const status = statusFor(state) ?? fallbackDockerStatus(runtimeHint);
|
||||
if (status === undefined) {
|
||||
return html`
|
||||
<section class="toolbar"><strong>Updates</strong></section>
|
||||
@@ -104,7 +115,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTermi
|
||||
.updates-command > span { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
<section class="toolbar"><strong>Updates</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
|
||||
<section class="toolbar"><strong>Updates</strong>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
|
||||
<section class="viewer updates-status">
|
||||
<section>
|
||||
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
|
||||
@@ -132,6 +143,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTermi
|
||||
<section class="updates-meta">
|
||||
<span>Generated ${status.generatedAt}</span>
|
||||
${status.release.latestVersion === undefined ? null : html`<span>Latest npm release ${status.release.latestVersion}</span>`}
|
||||
${status.release.checkedAt === undefined || status.release.skipped === true ? null : html`<span>Release checked ${status.release.checkedAt}</span>`}
|
||||
${status.release.skipped === true ? html`<span>Remote version check skipped.</span>` : null}
|
||||
${status.release.error === undefined ? null : html`<span>Remote version check failed: ${status.release.error}</span>`}
|
||||
</section>
|
||||
@@ -144,6 +156,17 @@ const plugin: PiWebPlugin = {
|
||||
name: "Updates",
|
||||
activate: ({ html, svg }) => ({
|
||||
contributions: {
|
||||
actions: [
|
||||
{
|
||||
id: "check",
|
||||
title: "Check for PI WEB Updates",
|
||||
description: "Bypass cached release data and check the selected machine now",
|
||||
group: "Updates",
|
||||
enabled: (context) => context.checkForPiWebUpdates !== undefined,
|
||||
disabledReason: () => "Update checks require a newer PI WEB gateway",
|
||||
run: (context) => context.checkForPiWebUpdates?.(),
|
||||
},
|
||||
],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "workspace.updates",
|
||||
@@ -157,10 +180,10 @@ const plugin: PiWebPlugin = {
|
||||
</svg>
|
||||
`,
|
||||
order: 100,
|
||||
visible: (context) => shouldShowUpdatesPanel(context.state),
|
||||
visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint),
|
||||
badge: (context) => {
|
||||
const count = messageCount(context.state);
|
||||
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
|
||||
return count > 0 ? count : undefined;
|
||||
},
|
||||
render: (context) => renderUpdatesPanel(html, context.terminal, context.state),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebComponentStatus, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
|
||||
import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic";
|
||||
import { additionalCommands, fallbackDockerStatus, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic";
|
||||
|
||||
function component(overrides: Partial<PiWebComponentStatus> = {}): PiWebComponentStatus {
|
||||
return {
|
||||
@@ -76,10 +76,35 @@ describe("recommendedCommand", () => {
|
||||
expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" });
|
||||
});
|
||||
|
||||
it("recommends restart when the session daemon is stale", () => {
|
||||
const result = recommendedCommand(status({
|
||||
components: {
|
||||
web: component(),
|
||||
sessiond: component({ component: "sessiond", label: "Session daemon", stale: true }),
|
||||
},
|
||||
commands: { restart: "pi-web restart" },
|
||||
}));
|
||||
expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" });
|
||||
});
|
||||
|
||||
it("returns nothing when everything is current and available", () => {
|
||||
expect(recommendedCommand(status({ commands: { restart: "pi-web restart" } }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit Docker command text", () => {
|
||||
expect(recommendedCommand(status({
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: true },
|
||||
commands: { update: "pi-web-docker update", restart: "pi-web-docker restart" },
|
||||
}))).toEqual({ label: "Update & restart everything", command: "pi-web-docker update" });
|
||||
expect(recommendedCommand(status({
|
||||
components: {
|
||||
web: component({ stale: true, installation: { kind: "docker", dockerMode: "dev" } }),
|
||||
sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "docker", dockerMode: "dev" } }),
|
||||
},
|
||||
commands: { restart: "pi-web-docker --dev restart" },
|
||||
}))).toEqual({ label: "Restart everything", command: "pi-web-docker --dev restart" });
|
||||
});
|
||||
|
||||
it("does not fabricate a restart command when one is not configured", () => {
|
||||
const result = recommendedCommand(status({
|
||||
components: {
|
||||
@@ -118,6 +143,39 @@ describe("additionalCommands", () => {
|
||||
{ label: "Status", command: "pi-web status" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("presents Docker runtime and development commands exactly as reported", () => {
|
||||
expect(additionalCommands(status({
|
||||
commands: {
|
||||
update: "pi-web-docker update",
|
||||
restart: "pi-web-docker restart",
|
||||
restartWeb: "pi-web-docker restart-web",
|
||||
restartSessiond: "pi-web-docker restart-sessiond",
|
||||
status: "pi-web-docker status",
|
||||
},
|
||||
}), undefined)).toEqual([
|
||||
{ label: "Update", command: "pi-web-docker update" },
|
||||
{ label: "Restart all", command: "pi-web-docker restart" },
|
||||
{ label: "Restart Web/UI", command: "pi-web-docker restart-web" },
|
||||
{ label: "Restart session daemon", command: "pi-web-docker restart-sessiond" },
|
||||
{ label: "Status", command: "pi-web-docker status" },
|
||||
]);
|
||||
|
||||
expect(additionalCommands(status({
|
||||
commands: {
|
||||
update: "pi-web-docker --dev update",
|
||||
restart: "pi-web-docker --dev restart",
|
||||
restartWeb: "pi-web-docker --dev restart-web",
|
||||
restartSessiond: "pi-web-docker --dev restart-sessiond",
|
||||
status: "pi-web-docker --dev status",
|
||||
},
|
||||
}), { label: "Update & restart everything", command: "pi-web-docker --dev update" })).toEqual([
|
||||
{ label: "Restart all", command: "pi-web-docker --dev restart" },
|
||||
{ label: "Restart Web/UI", command: "pi-web-docker --dev restart-web" },
|
||||
{ label: "Restart session daemon", command: "pi-web-docker --dev restart-sessiond" },
|
||||
{ label: "Status", command: "pi-web-docker --dev status" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowUpdatesPanel", () => {
|
||||
@@ -134,12 +192,17 @@ describe("shouldShowUpdatesPanel", () => {
|
||||
expect(shouldShowUpdatesPanel(stateWith(value))).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the panel when a federated Docker runtime hint is available before status is parsed", () => {
|
||||
expect(shouldShowUpdatesPanel(undefined, { dockerMode: "dev" })).toBe(true);
|
||||
expect(shouldShowUpdatesPanel(undefined, { dockerMode: "runtime" })).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the panel when status is unavailable", () => {
|
||||
expect(shouldShowUpdatesPanel(stateWith(undefined))).toBe(false);
|
||||
expect(shouldShowUpdatesPanel(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the panel for local or unknown installs", () => {
|
||||
it("shows the panel for local, Docker, or unknown installs", () => {
|
||||
const local = status({
|
||||
components: {
|
||||
web: component({ installation: { kind: "local" } }),
|
||||
@@ -148,6 +211,14 @@ describe("shouldShowUpdatesPanel", () => {
|
||||
});
|
||||
expect(shouldShowUpdatesPanel(stateWith(local))).toBe(true);
|
||||
|
||||
const docker = status({
|
||||
components: {
|
||||
web: component({ installation: { kind: "docker", dockerMode: "runtime" } }),
|
||||
sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "docker", dockerMode: "runtime" } }),
|
||||
},
|
||||
});
|
||||
expect(shouldShowUpdatesPanel(stateWith(docker))).toBe(true);
|
||||
|
||||
const unknown = status({
|
||||
components: {
|
||||
web: component({ installation: { kind: "pi-package" } }),
|
||||
@@ -168,6 +239,38 @@ describe("shouldShowUpdatesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallbackDockerStatus", () => {
|
||||
it("creates Docker development commands from a federated runtime hint", () => {
|
||||
const fallback = fallbackDockerStatus({ dockerMode: "dev" }, "generated");
|
||||
expect(fallback?.generatedAt).toBe("generated");
|
||||
expect(fallback?.components.web.installation).toEqual({ kind: "docker", dockerMode: "dev" });
|
||||
expect(fallback?.commands).toEqual({
|
||||
update: "pi-web-docker --dev update",
|
||||
restart: "pi-web-docker --dev restart",
|
||||
restartWeb: "pi-web-docker --dev restart-web",
|
||||
restartSessiond: "pi-web-docker --dev restart-sessiond",
|
||||
status: "pi-web-docker --dev status",
|
||||
});
|
||||
expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility");
|
||||
});
|
||||
|
||||
it("creates Docker runtime commands without the development prefix", () => {
|
||||
const fallback = fallbackDockerStatus({ dockerMode: "runtime" });
|
||||
expect(fallback?.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "runtime" });
|
||||
expect(fallback?.commands).toEqual({
|
||||
update: "pi-web-docker update",
|
||||
restart: "pi-web-docker restart",
|
||||
restartWeb: "pi-web-docker restart-web",
|
||||
restartSessiond: "pi-web-docker restart-sessiond",
|
||||
status: "pi-web-docker status",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a fallback without a Docker runtime hint", () => {
|
||||
expect(fallbackDockerStatus({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageCount", () => {
|
||||
it("counts messages and tolerates missing status", () => {
|
||||
expect(messageCount(undefined)).toBe(0);
|
||||
@@ -190,6 +293,8 @@ describe("installationLabel", () => {
|
||||
expect(installationLabel({ kind: "unknown" })).toBe("installation unknown");
|
||||
expect(installationLabel({ kind: "npm-global" })).toBe("global npm package");
|
||||
expect(installationLabel({ kind: "local" })).toBe("local checkout");
|
||||
expect(installationLabel({ kind: "docker", dockerMode: "runtime" })).toBe("Docker runtime");
|
||||
expect(installationLabel({ kind: "docker", dockerMode: "dev" })).toBe("Docker development runtime");
|
||||
});
|
||||
|
||||
it("includes source and scope for pi-package installs", () => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
|
||||
import type { PiWebDockerMode, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
|
||||
|
||||
export interface CommandEntry {
|
||||
label: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface UpdatesRuntimeHint {
|
||||
dockerMode?: PiWebDockerMode;
|
||||
}
|
||||
|
||||
// The single command users should run when they do not want to think: if an
|
||||
// update is available, `commands.update` already chains the update and a full
|
||||
// restart; otherwise, when anything is stale, a full restart is enough.
|
||||
@@ -45,16 +49,45 @@ export function messageCount(state: PluginRuntimeState | undefined): number {
|
||||
return messagesFor(state).length;
|
||||
}
|
||||
|
||||
export function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean {
|
||||
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
|
||||
export function isSelfManagedInstallation(installation: PiWebInstallationInfo | undefined): boolean {
|
||||
return installation === undefined || installation.kind === "local" || installation.kind === "docker" || installation.kind === "unknown";
|
||||
}
|
||||
|
||||
export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
|
||||
export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined, hint: UpdatesRuntimeHint = {}): boolean {
|
||||
const status = statusFor(state);
|
||||
if (hint.dockerMode !== undefined) return true;
|
||||
if (messageCount(state) > 0) return true;
|
||||
if (status === undefined) return false;
|
||||
return isLocalOrUnknownInstallation(status.components.web.installation)
|
||||
|| isLocalOrUnknownInstallation(status.components.sessiond.installation);
|
||||
return isSelfManagedInstallation(status.components.web.installation)
|
||||
|| isSelfManagedInstallation(status.components.sessiond.installation);
|
||||
}
|
||||
|
||||
export function fallbackDockerStatus(hint: UpdatesRuntimeHint, generatedAt = "federated status unavailable"): PiWebStatusResponse | undefined {
|
||||
if (hint.dockerMode === undefined) return undefined;
|
||||
const commandPrefix = hint.dockerMode === "dev" ? "pi-web-docker --dev" : "pi-web-docker";
|
||||
const installation: PiWebInstallationInfo = { kind: "docker", dockerMode: hint.dockerMode };
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt,
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", stale: false, available: true, installation },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true, installation },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false, skipped: true },
|
||||
commands: {
|
||||
update: `${commandPrefix} update`,
|
||||
restart: `${commandPrefix} restart`,
|
||||
restartWeb: `${commandPrefix} restart-web`,
|
||||
restartSessiond: `${commandPrefix} restart-sessiond`,
|
||||
status: `${commandPrefix} status`,
|
||||
},
|
||||
messages: [{
|
||||
id: "docker-status-compatibility",
|
||||
severity: "info",
|
||||
title: "Docker update commands available",
|
||||
body: "This Updates plugin was loaded from a Docker PI WEB runtime, but the gateway has not provided Docker-aware status details yet. The Docker maintenance commands below are still available.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatVersion(version: string | undefined): string {
|
||||
@@ -70,5 +103,6 @@ export function installationLabel(installation: PiWebInstallationInfo | undefine
|
||||
}
|
||||
if (installation.kind === "npm-global") return "global npm package";
|
||||
if (installation.kind === "local") return "local checkout";
|
||||
if (installation.kind === "docker") return installation.dockerMode === "dev" ? "Docker development runtime" : "Docker runtime";
|
||||
return "installation unknown";
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("workspace tasks config", () => {
|
||||
title: "Start Docker",
|
||||
description: "Start the dev stack.",
|
||||
group: "Docker",
|
||||
command: "./docker/scripts/docker-compose-dev up -d",
|
||||
command: "./docker/pi-web-docker --dev start",
|
||||
confirm: true,
|
||||
},
|
||||
],
|
||||
@@ -42,7 +42,7 @@ describe("workspace tasks config", () => {
|
||||
title: "Start Docker",
|
||||
description: "Start the dev stack.",
|
||||
group: "Docker",
|
||||
command: "./docker/scripts/docker-compose-dev up -d",
|
||||
command: "./docker/pi-web-docker --dev start",
|
||||
confirm: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const FULL_LINT_TRIGGERS = new Set([
|
||||
"eslint.config.js",
|
||||
"tsconfig.json",
|
||||
]);
|
||||
|
||||
const FULL_TEST_TRIGGERS = new Set([
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts",
|
||||
]);
|
||||
|
||||
const LINTABLE_ROOT_FILES = new Set([
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
]);
|
||||
|
||||
const LINTABLE_DIRECTORIES = [
|
||||
"extensions/",
|
||||
"pi-web-plugins/",
|
||||
"src/",
|
||||
];
|
||||
|
||||
const RELATED_SOURCE_DIRECTORIES = [
|
||||
"extensions/",
|
||||
"pi-web-plugins/",
|
||||
"plugin-api/",
|
||||
"scripts/",
|
||||
"src/",
|
||||
];
|
||||
|
||||
// `vitest related` follows imports, but these suites inspect repository assets at runtime.
|
||||
const DOCKER_TESTS = [
|
||||
"src/docker/piWebDockerDocs.test.ts",
|
||||
"src/docker/piWebDockerEntrypoint.test.ts",
|
||||
"src/server/dockerControlAssets.test.ts",
|
||||
];
|
||||
|
||||
const DOCKER_DOCS_TEST = "src/docker/piWebDockerDocs.test.ts";
|
||||
const PLUGIN_PUBLIC_API_TEST = "pi-web-plugins/pluginPublicApi.test.ts";
|
||||
|
||||
export function parseNullDelimitedPaths(output) {
|
||||
const value = Buffer.isBuffer(output) ? output.toString("utf8") : output;
|
||||
return value.split("\0").filter((path) => path.length > 0);
|
||||
}
|
||||
|
||||
export function createValidationPlan(stagedPaths, options = {}) {
|
||||
const pathExists = options.pathExists ?? existsSync;
|
||||
const paths = [...new Set(stagedPaths.map(normalizeRepoPath).filter((path) => path.length > 0))].sort();
|
||||
|
||||
const lint = paths.some((path) => FULL_LINT_TRIGGERS.has(path))
|
||||
? { mode: "full", files: [] }
|
||||
: scopedValidation(paths.filter((path) => isLintablePath(path) && pathExists(path)), "scoped");
|
||||
|
||||
const tests = paths.some((path) => FULL_TEST_TRIGGERS.has(path))
|
||||
? { mode: "full", files: [] }
|
||||
: scopedValidation(relatedTestInputs(paths), "related");
|
||||
|
||||
return { paths, lint, tests };
|
||||
}
|
||||
|
||||
export function createValidationSteps(plan) {
|
||||
const steps = [
|
||||
{
|
||||
label: "cached whole-project typecheck",
|
||||
npmArgs: ["run", "typecheck:cached"],
|
||||
},
|
||||
{
|
||||
label: "whole-project Knip analysis",
|
||||
npmArgs: ["run", "knip"],
|
||||
},
|
||||
];
|
||||
|
||||
if (plan.lint.mode === "full") {
|
||||
steps.push({ label: "full ESLint validation (configuration changed)", npmArgs: ["run", "lint"] });
|
||||
} else if (plan.lint.mode === "scoped") {
|
||||
steps.push({
|
||||
label: `ESLint validation for ${String(plan.lint.files.length)} staged file(s)`,
|
||||
npmArgs: ["exec", "--", "eslint", "--", ...plan.lint.files],
|
||||
});
|
||||
}
|
||||
|
||||
if (plan.tests.mode === "full") {
|
||||
steps.push({ label: "full Vitest validation (configuration changed)", npmArgs: ["test"] });
|
||||
} else if (plan.tests.mode === "related") {
|
||||
steps.push({
|
||||
label: `Vitest validation related to ${String(plan.tests.files.length)} staged input(s)`,
|
||||
npmArgs: [
|
||||
"exec",
|
||||
"--",
|
||||
"vitest",
|
||||
"related",
|
||||
"--run",
|
||||
"--config",
|
||||
"vitest.config.ts",
|
||||
"--passWithNoTests",
|
||||
...plan.tests.files,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function readStagedPaths() {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
["diff", "--cached", "--name-only", "--diff-filter=ACMRD", "-z"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] },
|
||||
);
|
||||
return parseNullDelimitedPaths(output);
|
||||
}
|
||||
|
||||
function relatedTestInputs(paths) {
|
||||
const inputs = new Set();
|
||||
|
||||
for (const path of paths) {
|
||||
if (isRelatedSourcePath(path)) inputs.add(path);
|
||||
|
||||
if (path.startsWith("docker/")) {
|
||||
for (const test of DOCKER_TESTS) inputs.add(test);
|
||||
} else if (path === "README.md" || path.startsWith("docs/")) {
|
||||
inputs.add(DOCKER_DOCS_TEST);
|
||||
}
|
||||
|
||||
if (path.startsWith("pi-web-plugins/")) inputs.add(PLUGIN_PUBLIC_API_TEST);
|
||||
}
|
||||
|
||||
return [...inputs].sort();
|
||||
}
|
||||
|
||||
function isLintablePath(path) {
|
||||
if (LINTABLE_ROOT_FILES.has(path)) return true;
|
||||
return path.endsWith(".ts") && LINTABLE_DIRECTORIES.some((directory) => path.startsWith(directory));
|
||||
}
|
||||
|
||||
function isRelatedSourcePath(path) {
|
||||
if (path === "plugin-api.d.ts") return true;
|
||||
if (!/\.(?:[cm]?[jt]s|[jt]sx|json)$/u.test(path)) return false;
|
||||
return RELATED_SOURCE_DIRECTORIES.some((directory) => path.startsWith(directory));
|
||||
}
|
||||
|
||||
function normalizeRepoPath(path) {
|
||||
return path.replaceAll("\\", "/").replace(/^\.\//u, "");
|
||||
}
|
||||
|
||||
function scopedValidation(files, mode) {
|
||||
return files.length > 0 ? { mode, files } : { mode: "skip", files: [] };
|
||||
}
|
||||
|
||||
function runNpmStep(step) {
|
||||
console.log(`\n[pre-commit] ${step.label}`);
|
||||
const invocation = npmInvocation(step.npmArgs);
|
||||
const result = spawnSync(invocation.command, invocation.args, { stdio: "inherit" });
|
||||
if (result.error !== undefined) throw result.error;
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
function npmInvocation(npmArgs) {
|
||||
const npmExecPath = process.env["npm_execpath"];
|
||||
if (npmExecPath !== undefined && npmExecPath.length > 0) {
|
||||
return { command: process.execPath, args: [npmExecPath, ...npmArgs] };
|
||||
}
|
||||
return {
|
||||
command: process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
args: npmArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const plan = createValidationPlan(readStagedPaths());
|
||||
console.log(`[pre-commit] Planning validation for ${String(plan.paths.length)} staged file(s).`);
|
||||
|
||||
for (const step of createValidationSteps(plan)) {
|
||||
const status = runNpmStep(step);
|
||||
if (status !== 0) return status;
|
||||
}
|
||||
|
||||
if (plan.lint.mode === "skip") console.log("\n[pre-commit] No staged files require ESLint.");
|
||||
if (plan.tests.mode === "skip") console.log("[pre-commit] No staged files have related Vitest coverage.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isDirectExecution() {
|
||||
const entryPath = process.argv[1];
|
||||
if (entryPath === undefined) return false;
|
||||
return pathToFileURL(resolve(entryPath)).href === import.meta.url;
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[pre-commit] ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createValidationPlan,
|
||||
createValidationSteps,
|
||||
parseNullDelimitedPaths,
|
||||
} from "./verify-staged.mjs";
|
||||
|
||||
describe("staged validation planning", () => {
|
||||
it("parses NUL-delimited Git paths without breaking spaces", () => {
|
||||
expect(parseNullDelimitedPaths(Buffer.from("src/one.ts\0src/path with spaces/two.ts\0"))).toEqual([
|
||||
"src/one.ts",
|
||||
"src/path with spaces/two.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes ESLint and Vitest to staged source files", () => {
|
||||
const plan = createValidationPlan([
|
||||
"src/client/src/components/ChatView.ts",
|
||||
"src/client/src/components/ChatView.test.ts",
|
||||
"README.md",
|
||||
], { pathExists: () => true });
|
||||
|
||||
expect(plan).toEqual({
|
||||
paths: [
|
||||
"README.md",
|
||||
"src/client/src/components/ChatView.test.ts",
|
||||
"src/client/src/components/ChatView.ts",
|
||||
],
|
||||
lint: {
|
||||
mode: "scoped",
|
||||
files: [
|
||||
"src/client/src/components/ChatView.test.ts",
|
||||
"src/client/src/components/ChatView.ts",
|
||||
],
|
||||
},
|
||||
tests: {
|
||||
mode: "related",
|
||||
files: [
|
||||
"src/client/src/components/ChatView.test.ts",
|
||||
"src/client/src/components/ChatView.ts",
|
||||
"src/docker/piWebDockerDocs.test.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not lint deleted files but still gives them to Vitest dependency analysis", () => {
|
||||
const plan = createValidationPlan(["src/shared/deleted.ts"], { pathExists: () => false });
|
||||
|
||||
expect(plan.lint).toEqual({ mode: "skip", files: [] });
|
||||
expect(plan.tests).toEqual({ mode: "related", files: ["src/shared/deleted.ts"] });
|
||||
});
|
||||
|
||||
it("adds tests for repository assets that are read dynamically", () => {
|
||||
const plan = createValidationPlan([
|
||||
"docker/internal/image/install-opensuse-base",
|
||||
"pi-web-plugins/updates/updatesLogic.ts",
|
||||
], { pathExists: () => true });
|
||||
|
||||
expect(plan.tests).toEqual({
|
||||
mode: "related",
|
||||
files: [
|
||||
"pi-web-plugins/pluginPublicApi.test.ts",
|
||||
"pi-web-plugins/updates/updatesLogic.ts",
|
||||
"src/docker/piWebDockerDocs.test.ts",
|
||||
"src/docker/piWebDockerEntrypoint.test.ts",
|
||||
"src/server/dockerControlAssets.test.ts",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs only the affected full validator when its configuration changes", () => {
|
||||
const eslintPlan = createValidationPlan(["eslint.config.js"], { pathExists: () => true });
|
||||
expect(eslintPlan.lint).toEqual({ mode: "full", files: [] });
|
||||
expect(eslintPlan.tests).toEqual({ mode: "skip", files: [] });
|
||||
|
||||
const vitestPlan = createValidationPlan(["vitest.config.ts"], { pathExists: () => true });
|
||||
expect(vitestPlan.lint).toEqual({ mode: "scoped", files: ["vitest.config.ts"] });
|
||||
expect(vitestPlan.tests).toEqual({ mode: "full", files: [] });
|
||||
|
||||
const typescriptPlan = createValidationPlan(["tsconfig.json"], { pathExists: () => true });
|
||||
expect(typescriptPlan.lint).toEqual({ mode: "full", files: [] });
|
||||
expect(typescriptPlan.tests).toEqual({ mode: "full", files: [] });
|
||||
});
|
||||
|
||||
it("always includes cached typechecking and Knip before scoped checks", () => {
|
||||
const plan = createValidationPlan(["./src/path with spaces/example.ts"], { pathExists: () => true });
|
||||
|
||||
expect(createValidationSteps(plan).map((step) => step.npmArgs)).toEqual([
|
||||
["run", "typecheck:cached"],
|
||||
["run", "knip"],
|
||||
["exec", "--", "eslint", "--", "src/path with spaces/example.ts"],
|
||||
[
|
||||
"exec",
|
||||
"--",
|
||||
"vitest",
|
||||
"related",
|
||||
"--run",
|
||||
"--config",
|
||||
"vitest.config.ts",
|
||||
"--passWithNoTests",
|
||||
"src/path with spaces/example.ts",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
+11
-8
@@ -32,7 +32,7 @@ Every relay has these three core files:
|
||||
- **Goal / finish line.** A concrete, achievable end state. Without this the relay runs forever — this is non-negotiable.
|
||||
- **Sizing.** How much is *one leg*? This is project- and plan-specific; the charter defines it (a task, a slice, a time/scope budget — whatever fits). The skill does not decide this for you.
|
||||
- **Task selection policy.** How a runner chooses the next task when `status.md` does not name one explicitly.
|
||||
- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. A normal handoff points at `charter.md` and `status.md`, not the full log.
|
||||
- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. A normal handoff starts with a natural header containing the relay name and next leg number, then points at `charter.md` and `status.md`, not the full log.
|
||||
- **Intervention signal.** When and how a runner must stop and get the human, and how that is made visible. The charter must define this; the skill does not define it for you.
|
||||
- **Reading discipline.** The files a runner should read to orient, and any files that should not be read defensively.
|
||||
|
||||
@@ -42,11 +42,12 @@ The charter *can* be edited, but it should rarely *need* to be. If it is changin
|
||||
|
||||
- **Current position.** Where the relay is now.
|
||||
- **Current or next task.** The next leg if known; otherwise enough information to apply the charter's task selection policy.
|
||||
- **Leg tracking.** The last completed leg and the next leg to run. Keep this explicit so runners do not have to infer whether “current leg” means the leg just finished or the leg being handed off, and so new PI-WEB sessions can distinguish relay legs from the first line of their prompt without naming instructions.
|
||||
- **Relevant context.** Only the files, sections, commands, artifacts, or specific log entries needed for the next leg.
|
||||
- **Progress documentation.** Where this runner must write progress: update `status.md`, append `log.md`, update artifacts, commit, etc.
|
||||
- **Blockers / intervention state.** Current risks, open decisions, or active reasons to stop.
|
||||
|
||||
Think of `status.md` as the thing passed from runner to runner. If it grows into a history dump, compress it back into current state plus pointers.
|
||||
Think of `status.md` as the thing passed from runner to runner. If it grows into a history dump, compress it back into current state plus pointers. If an older relay lacks leg tracking, repair it when you update status; prefer the leg number from the prompt or status, and do not read `log.md` end-to-end just to count prior legs.
|
||||
|
||||
**Log** (`log.md`) — append-only history. Each leg appends a concise entry recording what it did, decisions made and why, durable artifacts changed, status updates made, and blockers. The log preserves auditability, but it is **not** orientation memory.
|
||||
|
||||
@@ -70,19 +71,21 @@ If `status.md` is insufficient, fix the baton rather than compensating by readin
|
||||
|
||||
This is the loop you run when you are dispatched into a relay.
|
||||
|
||||
1. **Orient from the packet.** Read `charter.md` and `status.md`. Confirm the relay name/root, goal, sizing, handoff protocol, intervention signal, and current/next task. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are.
|
||||
1. **Orient from the packet.** Read `charter.md` and `status.md`. Confirm the relay name/root, goal, sizing, handoff protocol, last completed leg, next leg to run, intervention signal, and current/next task. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are.
|
||||
2. **Choose the leg.** Prefer the explicit current/next task in `status.md`. If none is named, apply the charter's task selection policy. If that still requires context, inspect only the referenced plan/backlog/artifact sections. If the next task is still ambiguous or would materially change direction, stop and involve the human.
|
||||
3. **Re-anchor to the goal.** Does the goal still make sense given the status and what you now see? If reality has diverged from the charter, that is often an intervention moment — don't quietly redefine the task.
|
||||
4. **Run one leg.** Do exactly one well-sized slice, per the charter's sizing. Resist doing "just a bit more" — extra scope bloats context and breaks the containment that makes Relay work.
|
||||
5. **Document progress.** Make all work durable. Update `status.md` with the new current state, next task or task-selection pointer, relevant context for the next runner, and blockers. Append a concise `log.md` entry with what you did, why, decisions made, artifacts changed, and whether you are handing off or stopping.
|
||||
5. **Document progress.** Make all work durable. Update `status.md` with the new current state, last completed leg, next leg to run (if any), next task or task-selection pointer, relevant context for the next runner, and blockers. Append a concise `log.md` entry with what you did, why, decisions made, artifacts changed, and whether you are handing off or stopping.
|
||||
6. **Decide: hand off, or stop.**
|
||||
- **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt that names the Relay method and points the next runner at `charter.md` and `status.md` (so this skill loads and they can orient cheaply). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the relay packet is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired.
|
||||
- **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt whose first line is a natural task header containing the relay name and next leg number (for example, `Relay "<name>" leg <N> begins now.`), followed by the Relay method and pointers to `charter.md` and `status.md` (so this skill loads and they can orient cheaply). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the relay packet is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired.
|
||||
- **Stop — do not spawn —** if the goal is reached, or you are blocked, or the charter's intervention signal fires. Update `status.md`, append a clear note in `log.md`, and raise the intervention signal so the watching human sees exactly what happened and what they need to decide. A stalled relay that stopped cleanly with a clear blocker is a success; a relay that spawned a confused next runner is a failure.
|
||||
|
||||
A good handoff prompt is short and explicit:
|
||||
A good handoff prompt is short and explicit. Put the relay identity and leg number at the very beginning so PI-WEB's session title generator sees useful distinguishing context without any naming instruction:
|
||||
|
||||
```text
|
||||
You are continuing Relay "<name>".
|
||||
Relay "<name>" leg <N> begins now.
|
||||
|
||||
You are the next runner in this Relay method chain.
|
||||
|
||||
Read:
|
||||
- .pi-web/relays/<name>/charter.md
|
||||
@@ -95,7 +98,7 @@ Run one leg according to the charter. Before handing off, update status.md, appe
|
||||
|
||||
## Planning a relay
|
||||
|
||||
When the user asks to set up a relay, your job is to produce the relay packet: `charter.md`, `status.md`, and `log.md`. The charter must have the required slots filled: relay identity, goal, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status must give the first runner a compact baton: current position, first task or task selection pointer, relevant context, documentation expectations, and known blockers. The log may start empty or with a short seed entry explaining that the relay was created.
|
||||
When the user asks to set up a relay, your job is to produce the relay packet: `charter.md`, `status.md`, and `log.md`. The charter must have the required slots filled: relay identity, goal, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status must give the first runner a compact baton: current position, leg tracking (usually last completed leg 0 and next leg to run 1 for a new relay), first task or task selection pointer, relevant context, documentation expectations, and known blockers. The log may start empty or with a short seed entry explaining that the relay was created.
|
||||
|
||||
Draw the required choices out from the user rather than inventing them: ask what the finish line is, how much should be one leg, how runners pick tasks, how runners hand off, what they should read, and when they must stop and get the human. Sizing, task selection, and the intervention signal especially are the user's to decide — propose options if it helps them think, but do not quietly settle them yourself.
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
"id": 0,
|
||||
"name": "plan-a-relay",
|
||||
"prompt": "I want to migrate all our REST endpoints to the new validation layer — there are around 40 of them across src/server/routes. I won't be able to babysit this. Set it up as a relay so an agent can grind through it across sessions and only pull me in when it actually needs me.",
|
||||
"expected_output": "Produces a relay packet (default .pi-web/relays/<name>/) with charter.md, status.md, and log.md. The charter has all required slots present: relay identity/root, goal/finish-line, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status is a compact baton with current position, first task or task-selection pointer, relevant context, progress documentation expectations, and known blockers. The agent asks the user to make sizing, task selection, reading discipline, and the intervention signal concrete rather than inventing strict rules. It does not prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the packet is agreed.",
|
||||
"expected_output": "Produces a relay packet (default .pi-web/relays/<name>/) with charter.md, status.md, and log.md. The charter has all required slots present: relay identity/root, goal/finish-line, sizing, task selection policy, handover, intervention signal, and reading discipline. The handover guidance says the spawn prompt starts with the relay name and leg number before pointing at charter.md and status.md. The initial status is a compact baton with current position, leg tracking for the last completed leg and next leg to run, first task or task-selection pointer, relevant context, progress documentation expectations, and known blockers. The agent asks the user to make sizing, task selection, reading discipline, and the intervention signal concrete rather than inventing strict rules. It does not prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the packet is agreed.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{ "name": "packet-created", "text": "A relay packet is created with charter.md, status.md, and log.md under the relay location (default .pi-web/relays/<name>/ unless specified).", "type": "script" },
|
||||
{ "name": "goal-slot-present", "text": "The charter defines a concrete, achievable finish line / goal.", "type": "judgment" },
|
||||
{ "name": "sizing-slot-present", "text": "The charter states how much work is one leg (sizing), rather than leaving it undefined.", "type": "judgment" },
|
||||
{ "name": "task-selection-slot-present", "text": "The charter states how a runner chooses the next task when status.md does not name one explicitly.", "type": "judgment" },
|
||||
{ "name": "handover-slot-present", "text": "The charter states the handover mechanism, including that the next runner reads charter.md and status.md.", "type": "judgment" },
|
||||
{ "name": "handover-slot-present", "text": "The charter states the handover mechanism, including that the handoff prompt starts with the relay name and next leg number and that the next runner reads charter.md and status.md.", "type": "judgment" },
|
||||
{ "name": "intervention-slot-present", "text": "The charter defines an intervention signal: when/how a runner stops and gets the human.", "type": "judgment" },
|
||||
{ "name": "reading-discipline-present", "text": "The charter states the reading discipline, including not reading log.md end-to-end by default.", "type": "judgment" },
|
||||
{ "name": "status-seeded", "text": "status.md is seeded as a compact baton with current position, first task or task-selection pointer, relevant context, documentation expectations, and known blockers.", "type": "judgment" },
|
||||
{ "name": "status-seeded", "text": "status.md is seeded as a compact baton with current position, leg tracking for the last completed leg and next leg to run, first task or task-selection pointer, relevant context, documentation expectations, and known blockers.", "type": "judgment" },
|
||||
{ "name": "asks-not-prescribes", "text": "For sizing, task selection, reading discipline, and the intervention signal, the agent asks the user to make them concrete instead of imposing its own strict rules/cadence.", "type": "judgment" },
|
||||
{ "name": "no-premature-spawn", "text": "The agent does not spawn the first leg before the relay packet is agreed with the user.", "type": "script" }
|
||||
]
|
||||
@@ -25,7 +25,7 @@
|
||||
"id": 1,
|
||||
"name": "run-one-leg-and-hand-off",
|
||||
"prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/status.md, continue the plan, then dispatch the next agent.",
|
||||
"expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter.md and status.md, not the full log. Re-anchors to the goal, chooses the next task from status.md or the charter's task-selection policy, does exactly ONE well-sized leg per the charter's sizing, updates status.md as a compact baton, appends a concise log.md entry, makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that names Relay and points at charter.md and status.md. Does not do extra legs, does not spawn more than once, and does not tell the next runner to read log.md end-to-end.",
|
||||
"expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter.md and status.md, not the full log. Re-anchors to the goal, chooses the next task from status.md or the charter's task-selection policy, does exactly ONE well-sized leg per the charter's sizing, updates status.md as a compact baton, appends a concise log.md entry, makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that starts with the relay name and next leg number, names Relay, and points at charter.md and status.md. Does not do extra legs, does not spawn more than once, and does not tell the next runner to read log.md end-to-end.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{ "name": "skill-loads-from-handoff", "text": "The agent recognizes it is in a relay and loads/consults the relay skill from the handoff prompt.", "type": "judgment" },
|
||||
@@ -37,7 +37,7 @@
|
||||
{ "name": "log-appended", "text": "A concise log.md entry is appended recording what was done, decisions, artifacts changed, status updates made, and any blocker.", "type": "script" },
|
||||
{ "name": "work-durable-before-handoff", "text": "Work is saved (and committed if the charter requires it) before spawn_session is called.", "type": "script" },
|
||||
{ "name": "spawn-exactly-once", "text": "spawn_session is called exactly once.", "type": "script" },
|
||||
{ "name": "handoff-names-relay-and-status", "text": "The spawn prompt names the Relay framework and points the next runner at charter.md and status.md, not the full log, so the skill loads downstream with bounded context.", "type": "judgment" }
|
||||
{ "name": "handoff-names-relay-leg-and-status", "text": "The spawn prompt starts with the relay name and next leg number, names the Relay framework, and points the next runner at charter.md and status.md, not the full log, so the skill loads downstream with bounded context and PI-WEB can generate a distinguishable session name.", "type": "judgment" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -75,7 +75,10 @@ So when testing or running a relay whose packet lives outside the repo, keep `cw
|
||||
spawn_session cwd: <project-root>
|
||||
|
||||
Prompt:
|
||||
You are continuing Relay "sandbox".
|
||||
Relay "sandbox" leg 2 begins now.
|
||||
|
||||
You are the next runner in this Relay method chain.
|
||||
|
||||
Read:
|
||||
- /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/charter.md
|
||||
- /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/status.md
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
describe("production build contents", () => {
|
||||
// Constructing the full compiler graph can exceed Vitest's default timeout under parallel-suite CPU contention.
|
||||
it("keeps test-support modules out of the TypeScript build graph", { timeout: 15_000 }, () => {
|
||||
const buildConfig = readBuildConfig();
|
||||
const program = ts.createProgram({ rootNames: buildConfig.fileNames, options: buildConfig.options });
|
||||
const projectSources = program.getSourceFiles()
|
||||
.map((sourceFile) => normalizePath(relative(repoRoot, sourceFile.fileName)))
|
||||
.filter((path) => path.startsWith("src/"));
|
||||
|
||||
expect(projectSources).toContain("src/server/app.ts");
|
||||
expect(projectSources.filter(isTestSupportPath)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps test-support artifacts out of the npm tarball", async () => {
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), "pi-web-package-contents-"));
|
||||
try {
|
||||
const fixtureDist = join(fixtureRoot, "dist", "server");
|
||||
await mkdir(fixtureDist, { recursive: true });
|
||||
await Promise.all([
|
||||
copyFile(join(repoRoot, "package.json"), join(fixtureRoot, "package.json")),
|
||||
writeFile(join(fixtureDist, "app.js"), "export {};\n", "utf8"),
|
||||
writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"),
|
||||
writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"),
|
||||
]);
|
||||
|
||||
const npmExecPath = process.env["npm_execpath"];
|
||||
if (npmExecPath === undefined || npmExecPath.length === 0) {
|
||||
throw new Error("npm_execpath is required to verify npm package contents");
|
||||
}
|
||||
const stdout = await execUtf8(process.execPath, [npmExecPath, "pack", "--dry-run", "--json", "--ignore-scripts"], fixtureRoot);
|
||||
const packagedFiles = packageFilePaths(stdout);
|
||||
|
||||
expect(packagedFiles).toContain("dist/server/app.js");
|
||||
expect(packagedFiles.filter(isTestSupportPath)).toEqual([]);
|
||||
} finally {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function readBuildConfig(): ts.ParsedCommandLine {
|
||||
const configPath = join(repoRoot, "tsconfig.build.json");
|
||||
const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
|
||||
...ts.sys,
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw new Error(formatDiagnostics([diagnostic]));
|
||||
},
|
||||
});
|
||||
if (config === undefined) throw new Error(`Unable to parse ${configPath}`);
|
||||
if (config.errors.length > 0) throw new Error(formatDiagnostics(config.errors));
|
||||
return config;
|
||||
}
|
||||
|
||||
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string {
|
||||
return ts.formatDiagnostics(diagnostics, {
|
||||
getCanonicalFileName: (fileName) => fileName,
|
||||
getCurrentDirectory: () => repoRoot,
|
||||
getNewLine: () => "\n",
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.split(sep).join("/");
|
||||
}
|
||||
|
||||
function isTestSupportPath(path: string): boolean {
|
||||
return path.includes(".testSupport.");
|
||||
}
|
||||
|
||||
function execUtf8(file: string, args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
execFile(file, args, { cwd, encoding: "utf8" }, (error, stdout) => {
|
||||
if (error !== null) {
|
||||
reject(error instanceof Error ? error : new Error("Command failed"));
|
||||
return;
|
||||
}
|
||||
resolvePromise(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function packageFilePaths(output: string): string[] {
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) throw new Error("npm pack returned an unexpected result");
|
||||
|
||||
const packResult: unknown = parsed[0];
|
||||
if (!isRecord(packResult)) throw new Error("npm pack result was not an object");
|
||||
const filesValue = packResult["files"];
|
||||
if (!Array.isArray(filesValue)) throw new Error("npm pack result did not include files");
|
||||
const files: unknown[] = filesValue;
|
||||
|
||||
return files.map((file) => {
|
||||
if (!isRecord(file) || typeof file["path"] !== "string") {
|
||||
throw new Error("npm pack returned an invalid file entry");
|
||||
}
|
||||
return file["path"];
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
+45
-1
@@ -2,7 +2,15 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { agentCommandForChecks, commandWithVersionCheck, isCliEntrypoint } from "./cli.js";
|
||||
import {
|
||||
agentCommandForChecks,
|
||||
commandWithVersionCheck,
|
||||
doctorExitCode,
|
||||
isCliEntrypoint,
|
||||
launchdRuntimeDetails,
|
||||
regularFileExists,
|
||||
serviceBackendForPlatform,
|
||||
} from "./cli.js";
|
||||
|
||||
const originalShell = process.env["SHELL"];
|
||||
const originalPiWebConfig = process.env["PI_WEB_CONFIG"];
|
||||
@@ -66,6 +74,42 @@ describe("agentCommandForChecks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native-service doctor CLI contracts", () => {
|
||||
it("uses native services only on supported platforms", () => {
|
||||
expect(serviceBackendForPlatform("linux")).toEqual({ kind: "systemd", label: "systemd user services" });
|
||||
expect(serviceBackendForPlatform("darwin")).toEqual({ kind: "launchd", label: "LaunchAgents" });
|
||||
expect(serviceBackendForPlatform("win32")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails doctor for general, native-plan, or node-pty failures", () => {
|
||||
expect(doctorExitCode(true, true, true)).toBe(0);
|
||||
expect(doctorExitCode(false, true, true)).toBe(1);
|
||||
expect(doctorExitCode(true, false, true)).toBe(1);
|
||||
expect(doctorExitCode(true, true, false)).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts only regular files as bundled entrypoints", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-web-entrypoint-test-"));
|
||||
try {
|
||||
const file = join(dir, "entrypoint.js");
|
||||
writeFileSync(file, "export {};\n");
|
||||
expect(regularFileExists(file)).toBe(true);
|
||||
expect(regularFileExists(dir)).toBe(false);
|
||||
expect(regularFileExists(join(dir, "missing.js"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces launchd last exit code 127 in service status", () => {
|
||||
expect(launchdRuntimeDetails("state = exited\nlast exit code = 127\n")).toEqual({
|
||||
state: "exited",
|
||||
detail: "exited (last exit code 127)",
|
||||
pid: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCliEntrypoint", () => {
|
||||
it("matches direct execution paths", () => {
|
||||
expect(isCliEntrypoint("/tmp/pi-web-cli.js", "/tmp/pi-web-cli.js")).toBe(true);
|
||||
|
||||
+335
-371
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir, userInfo } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
@@ -8,6 +8,37 @@ import { fileURLToPath } from "node:url";
|
||||
import { defaultPiWebConfigPath, defaultPiWebDataDir, effectiveAgentConfig, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
|
||||
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
|
||||
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
|
||||
import {
|
||||
installNativeServiceCandidate,
|
||||
nativeServiceInstallFailureNeedsPathAdvice,
|
||||
type NativeServiceInstallCandidate,
|
||||
type NativeServiceInstallFailure,
|
||||
} from "./nativeServices/serviceInstall.js";
|
||||
import {
|
||||
nativeServiceManagerRefs,
|
||||
productionNativeServiceIds,
|
||||
type NativeServiceBackend,
|
||||
type NativeServiceId,
|
||||
type NativeServiceManagerRef,
|
||||
type NativeServicePlan,
|
||||
type NativeServiceShell,
|
||||
type ProductionNativeServicePlanInput,
|
||||
} from "./nativeServices/servicePlan.js";
|
||||
import {
|
||||
formatNativeServiceDoctorResult,
|
||||
inferInstalledNativeServiceMode,
|
||||
inspectInstalledDevelopmentServiceInput,
|
||||
inspectInstalledProductionServiceContext,
|
||||
runNativeServiceDoctor,
|
||||
type InstalledNativeServiceDefinition,
|
||||
type NativeServiceDoctorReport,
|
||||
type NativeServiceDoctorTarget,
|
||||
} from "./nativeServices/serviceDoctor.js";
|
||||
import {
|
||||
createNativeServiceAuthoritativeProbe,
|
||||
nativeServicePrerequisiteShellCheck,
|
||||
} from "./nativeServices/serviceProbe.js";
|
||||
import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
|
||||
@@ -15,16 +46,10 @@ const systemdServiceDir = join(homedir(), ".config", "systemd", "user");
|
||||
const launchdServiceDir = join(homedir(), "Library", "LaunchAgents");
|
||||
const logDir = join(defaultPiWebDataDir(), "logs");
|
||||
|
||||
const sessiondServiceName = "pi-web-sessiond.service";
|
||||
const webServiceName = "pi-web.service";
|
||||
const uiDevServiceName = "pi-web-ui-dev.service";
|
||||
|
||||
type InstallMode = "production" | "dev";
|
||||
type ServiceBackendKind = "systemd" | "launchd";
|
||||
type ServiceId = "sessiond" | "web" | "uiDev";
|
||||
type ServiceId = NativeServiceId;
|
||||
type ServiceBackend = NativeServiceBackend;
|
||||
type Check = [string, string[]];
|
||||
type SupportedShell = "bash" | "zsh" | "fish";
|
||||
type RestartPolicy = "on-failure" | "never";
|
||||
|
||||
interface InstallOptions {
|
||||
host: string;
|
||||
@@ -33,44 +58,8 @@ interface InstallOptions {
|
||||
config?: string;
|
||||
}
|
||||
|
||||
interface ServiceBackend {
|
||||
kind: ServiceBackendKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ServiceRef {
|
||||
interface ServiceRef extends NativeServiceManagerRef {
|
||||
id: ServiceId;
|
||||
systemdName: string;
|
||||
launchdLabel: string;
|
||||
launchdPlistName: string;
|
||||
logName: string;
|
||||
}
|
||||
|
||||
interface ServiceDefinition extends ServiceRef {
|
||||
description: string;
|
||||
shellCommand: string;
|
||||
restart: RestartPolicy;
|
||||
environment: Record<string, string>;
|
||||
after?: ServiceId[];
|
||||
wants?: ServiceId[];
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
interface ServiceShell {
|
||||
name: SupportedShell;
|
||||
executable: string;
|
||||
detected?: string;
|
||||
fallback: boolean;
|
||||
}
|
||||
|
||||
interface ServiceExecutable {
|
||||
command: string;
|
||||
checks: Check[];
|
||||
}
|
||||
|
||||
interface ServiceExecutables {
|
||||
sessiond: ServiceExecutable;
|
||||
web: ServiceExecutable;
|
||||
}
|
||||
|
||||
type ServiceHealth = "running" | "stopped" | "not-installed" | "unknown";
|
||||
@@ -85,30 +74,12 @@ interface ServiceRuntimeStatus {
|
||||
}
|
||||
|
||||
const serviceRefs: Record<ServiceId, ServiceRef> = {
|
||||
sessiond: {
|
||||
id: "sessiond",
|
||||
systemdName: sessiondServiceName,
|
||||
launchdLabel: "com.pi-web.sessiond",
|
||||
launchdPlistName: "com.pi-web.sessiond.plist",
|
||||
logName: "sessiond.log",
|
||||
},
|
||||
web: {
|
||||
id: "web",
|
||||
systemdName: webServiceName,
|
||||
launchdLabel: "com.pi-web.web",
|
||||
launchdPlistName: "com.pi-web.web.plist",
|
||||
logName: "web.log",
|
||||
},
|
||||
uiDev: {
|
||||
id: "uiDev",
|
||||
systemdName: uiDevServiceName,
|
||||
launchdLabel: "com.pi-web.ui-dev",
|
||||
launchdPlistName: "com.pi-web.ui-dev.plist",
|
||||
logName: "ui-dev.log",
|
||||
},
|
||||
sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond },
|
||||
web: { id: "web", ...nativeServiceManagerRefs.web },
|
||||
uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev },
|
||||
};
|
||||
|
||||
const productionServiceIds: ServiceId[] = ["sessiond", "web"];
|
||||
const productionServiceIds: ServiceId[] = [...productionNativeServiceIds];
|
||||
const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
|
||||
const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"];
|
||||
// Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web
|
||||
@@ -123,12 +94,16 @@ function platformLabel(): string {
|
||||
return process.platform;
|
||||
}
|
||||
|
||||
function currentServiceBackend(): ServiceBackend | undefined {
|
||||
if (process.platform === "linux") return { kind: "systemd", label: "systemd user services" };
|
||||
if (process.platform === "darwin") return { kind: "launchd", label: "LaunchAgents" };
|
||||
export function serviceBackendForPlatform(platform: NodeJS.Platform): ServiceBackend | undefined {
|
||||
if (platform === "linux") return { kind: "systemd", label: "systemd user services" };
|
||||
if (platform === "darwin") return { kind: "launchd", label: "LaunchAgents" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function currentServiceBackend(): ServiceBackend | undefined {
|
||||
return serviceBackendForPlatform(process.platform);
|
||||
}
|
||||
|
||||
function requireServiceBackend(command: string): ServiceBackend {
|
||||
const backend = currentServiceBackend();
|
||||
if (backend !== undefined) return backend;
|
||||
@@ -178,7 +153,7 @@ function runQuiet(command: string, args: string[]): number {
|
||||
}
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]).status === 0;
|
||||
return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellSingleQuote(command)}`]).status === 0;
|
||||
}
|
||||
|
||||
function isLingerEnabled(): boolean | undefined {
|
||||
@@ -236,23 +211,6 @@ function fishSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
function systemdEscape(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
}
|
||||
|
||||
function systemdQuotedValue(value: string): string {
|
||||
return `"${systemdEscape(value)}"`;
|
||||
}
|
||||
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function packageRootPath(): string {
|
||||
return dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
}
|
||||
@@ -261,15 +219,29 @@ function packageEntrypointPath(name: "server" | "sessiond"): string {
|
||||
return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js");
|
||||
}
|
||||
|
||||
function detectServiceShell(): ServiceShell {
|
||||
export function regularFileExists(path: string): boolean {
|
||||
return existsSync(path) && statSync(path).isFile();
|
||||
}
|
||||
|
||||
function detectServiceShell(): NativeServiceShell {
|
||||
const userShell = userInfo().shell ?? undefined;
|
||||
const envShell = process.env["SHELL"]?.trim();
|
||||
const detected = envShell === undefined || envShell === "" ? userShell : envShell;
|
||||
const name = basename(detected ?? "").replace(/^-/, "");
|
||||
if (name === "bash" || name === "zsh" || name === "fish") {
|
||||
return { name, executable: detected ?? name, detected: detected ?? name, fallback: false };
|
||||
return {
|
||||
name,
|
||||
executable: detected ?? name,
|
||||
source: "detected",
|
||||
detectedExecutable: detected ?? name,
|
||||
};
|
||||
}
|
||||
return { name: "bash", executable: "bash", ...(detected === undefined ? {} : { detected }), fallback: true };
|
||||
return {
|
||||
name: "bash",
|
||||
executable: "bash",
|
||||
source: "fallback",
|
||||
detectedExecutable: detected ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function serviceShellCommand(command: string, cwd?: string): string[] {
|
||||
@@ -277,76 +249,18 @@ function serviceShellCommand(command: string, cwd?: string): string[] {
|
||||
return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand];
|
||||
}
|
||||
|
||||
function serviceShellExecPrefix(): string {
|
||||
return `/usr/bin/env ${detectServiceShell().executable} -lc`;
|
||||
}
|
||||
|
||||
function serviceShellQuote(value: string): string {
|
||||
return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
|
||||
}
|
||||
|
||||
function systemdServiceShellQuote(value: string): string {
|
||||
return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$"));
|
||||
}
|
||||
|
||||
function checkSucceeds(command: string[]): boolean {
|
||||
const [bin, ...args] = command;
|
||||
return bin !== undefined && capture(bin, args).status === 0;
|
||||
}
|
||||
|
||||
function serviceShellCanFindCommand(command: string, backend: ServiceBackend): boolean {
|
||||
if (!checkSucceeds(serviceShellCommand(commandCheck(command)))) return false;
|
||||
if (backend.kind === "systemd") return checkSucceeds(systemdUserServiceShellCommand(commandCheck(command)));
|
||||
return true;
|
||||
}
|
||||
|
||||
function readableFileCheck(path: string): string {
|
||||
const quoted = serviceShellQuote(path);
|
||||
return `test -r ${quoted} && printf '%s\\n' ${quoted}`;
|
||||
}
|
||||
|
||||
function commandExecutable(command: string, backend: ServiceBackend): ServiceExecutable {
|
||||
const shell = serviceShellLabel();
|
||||
const checks: Check[] = [[`${shell} can find ${command}`, serviceShellCommand(commandCheck(command))]];
|
||||
if (backend.kind === "systemd") {
|
||||
checks.push([`systemd user ${shell} can find ${command}`, systemdUserServiceShellCommand(commandCheck(command))]);
|
||||
}
|
||||
return { command, checks };
|
||||
}
|
||||
|
||||
function bundledExecutable(command: string, entrypointPath: string, backend: ServiceBackend): ServiceExecutable {
|
||||
const shell = serviceShellLabel();
|
||||
const check = readableFileCheck(entrypointPath);
|
||||
const checks: Check[] = [[`${shell} can access bundled ${command} entrypoint`, serviceShellCommand(check)]];
|
||||
if (backend.kind === "systemd") {
|
||||
checks.push([`systemd user ${shell} can access bundled ${command} entrypoint`, systemdUserServiceShellCommand(check)]);
|
||||
}
|
||||
return { command: `node ${serviceShellQuote(entrypointPath)}`, checks };
|
||||
}
|
||||
|
||||
function serviceExecutable(envName: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC", command: string, entrypointPath: string, backend: ServiceBackend): ServiceExecutable {
|
||||
const configured = process.env[envName]?.trim();
|
||||
if (configured !== undefined && configured !== "") return { command: configured, checks: [] };
|
||||
if (serviceShellCanFindCommand(command, backend)) return commandExecutable(command, backend);
|
||||
if (existsSync(entrypointPath)) return bundledExecutable(command, entrypointPath, backend);
|
||||
return commandExecutable(command, backend);
|
||||
}
|
||||
|
||||
function resolveServiceExecutables(backend: ServiceBackend): ServiceExecutables {
|
||||
return {
|
||||
sessiond: serviceExecutable("PI_WEB_SESSIOND_EXEC", "pi-web-sessiond", packageEntrypointPath("sessiond"), backend),
|
||||
web: serviceExecutable("PI_WEB_SERVER_EXEC", "pi-web-server", packageEntrypointPath("server"), backend),
|
||||
};
|
||||
}
|
||||
|
||||
function describeServiceShell(): string {
|
||||
const shell = detectServiceShell();
|
||||
if (shell.fallback) {
|
||||
return shell.detected === undefined
|
||||
if (shell.source === "fallback") {
|
||||
return shell.detectedExecutable === null
|
||||
? "could not detect a supported login shell; using bash"
|
||||
: `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`;
|
||||
: `detected ${shell.detectedExecutable}; using bash because PI WEB currently supports bash, zsh, and fish`;
|
||||
}
|
||||
return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`;
|
||||
return shell.detectedExecutable === null ? shell.name : `${shell.name} (${shell.detectedExecutable})`;
|
||||
}
|
||||
|
||||
function configEnvironment(options: InstallOptions, configPath: string): Record<string, string> {
|
||||
@@ -385,28 +299,6 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] {
|
||||
return orderServiceRefs(refs, restartServiceOrder);
|
||||
}
|
||||
|
||||
function productionServiceDefinitions(options: InstallOptions, configPath: string, executables: ServiceExecutables): ServiceDefinition[] {
|
||||
const environment = configEnvironment(options, configPath);
|
||||
return [
|
||||
{
|
||||
...serviceRefs.sessiond,
|
||||
description: "PI WEB session daemon",
|
||||
shellCommand: `exec ${executables.sessiond.command}`,
|
||||
restart: "on-failure",
|
||||
environment,
|
||||
},
|
||||
{
|
||||
...serviceRefs.web,
|
||||
description: "PI WEB server",
|
||||
shellCommand: `exec ${executables.web.command}`,
|
||||
restart: "on-failure",
|
||||
environment,
|
||||
after: ["sessiond"],
|
||||
wants: ["sessiond"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function devRootPath(): string {
|
||||
return resolve(process.cwd());
|
||||
}
|
||||
@@ -421,104 +313,21 @@ function validateDevCheckout(root: string): void {
|
||||
if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) {
|
||||
throw new Error(`Development mode must be installed from a PI WEB checkout. ${packageJsonPath} is not ${PI_WEB_PACKAGE_NAME}.`);
|
||||
}
|
||||
|
||||
const scripts = parsed["scripts"];
|
||||
if (!isRecord(scripts)) throw new Error(`Development mode requires npm scripts in ${packageJsonPath}.`);
|
||||
const requiredScripts = ["start:sessiond", "dev:web", "dev:client"];
|
||||
const missing = requiredScripts.filter((script) => typeof scripts[script] !== "string");
|
||||
if (missing.length > 0) throw new Error(`Development mode requires missing npm scripts: ${missing.join(", ")}.`);
|
||||
}
|
||||
|
||||
function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] {
|
||||
const environment = configEnvironment(options, configPath);
|
||||
return [
|
||||
{
|
||||
...serviceRefs.sessiond,
|
||||
description: "PI WEB session daemon (dev)",
|
||||
shellCommand: "exec npm run start:sessiond",
|
||||
restart: "never",
|
||||
environment,
|
||||
workingDirectory: root,
|
||||
},
|
||||
{
|
||||
...serviceRefs.uiDev,
|
||||
description: "PI WEB UI dev server",
|
||||
shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`,
|
||||
restart: "never",
|
||||
environment,
|
||||
after: ["sessiond"],
|
||||
wants: ["sessiond"],
|
||||
workingDirectory: root,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function dependencyLine(name: "After" | "Wants", ids: ServiceId[] | undefined): string {
|
||||
if (ids === undefined || ids.length === 0) return "";
|
||||
return `${name}=${ids.map((id) => serviceRefs[id].systemdName).join(" ")}\n`;
|
||||
}
|
||||
|
||||
function environmentLines(environment: Record<string, string>): string {
|
||||
return Object.entries(environment)
|
||||
.map(([key, value]) => `Environment="${key}=${systemdEscape(value)}"\n`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function systemdUnit(service: ServiceDefinition): string {
|
||||
const workingDirectory = service.workingDirectory === undefined ? "" : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`;
|
||||
const restart = service.restart === "on-failure" ? "Restart=on-failure\nRestartSec=2\n" : "Restart=no\n";
|
||||
return `[Unit]
|
||||
Description=${service.description}
|
||||
${dependencyLine("After", service.after)}${dependencyLine("Wants", service.wants)}
|
||||
[Service]
|
||||
Type=simple
|
||||
${workingDirectory}${environmentLines(service.environment)}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(service.shellCommand)}
|
||||
${restart}
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
function plistString(key: string, value: string, indent = " "): string {
|
||||
return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
|
||||
}
|
||||
|
||||
function plistProgramArguments(service: ServiceDefinition): string {
|
||||
const args = ["/usr/bin/env", detectServiceShell().executable, "-lc", service.shellCommand];
|
||||
return ` <key>ProgramArguments</key>\n <array>\n${args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n")}\n </array>\n`;
|
||||
}
|
||||
|
||||
function plistEnvironment(environment: Record<string, string>): string {
|
||||
const entries = Object.entries(environment);
|
||||
if (entries.length === 0) return "";
|
||||
return ` <key>EnvironmentVariables</key>\n <dict>\n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
|
||||
}
|
||||
|
||||
function launchdLogPath(ref: ServiceRef): string {
|
||||
return join(logDir, ref.logName);
|
||||
}
|
||||
|
||||
function launchdPlist(service: ServiceDefinition): string {
|
||||
const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory);
|
||||
const keepAlive = service.restart === "on-failure" ? " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n" : "";
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
${plistString("Label", service.launchdLabel)}${plistProgramArguments(service)}${workingDirectory}${plistEnvironment(service.environment)} <key>RunAtLoad</key>
|
||||
<true/>
|
||||
${keepAlive}${plistString("StandardOutPath", launchdLogPath(service))}${plistString("StandardErrorPath", launchdLogPath(service))}</dict>
|
||||
</plist>
|
||||
`;
|
||||
function installConfigPath(options: InstallOptions): string {
|
||||
return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
|
||||
}
|
||||
|
||||
async function writeInitialConfig(options: InstallOptions): Promise<string> {
|
||||
const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
|
||||
async function writeInitialConfig(options: InstallOptions, configPath: string): Promise<void> {
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
if (!existsSync(configPath)) {
|
||||
await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) }));
|
||||
}
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function systemdServicePath(ref: ServiceRef): string {
|
||||
@@ -546,8 +355,8 @@ function installedServiceRefs(backend: ServiceBackend): ServiceRef[] {
|
||||
return installed.length === 0 ? productionServiceRefs() : installed;
|
||||
}
|
||||
|
||||
async function installSystemdServices(services: ServiceDefinition[]): Promise<void> {
|
||||
const selected = new Set<ServiceId>(services.map((service) => service.id));
|
||||
async function installSystemdServices(plan: NativeServicePlan): Promise<void> {
|
||||
const selected = new Set<ServiceId>(plan.services.map((service) => service.id));
|
||||
const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
|
||||
|
||||
for (const ref of obsolete) {
|
||||
@@ -556,11 +365,11 @@ async function installSystemdServices(services: ServiceDefinition[]): Promise<vo
|
||||
}
|
||||
|
||||
await mkdir(systemdServiceDir, { recursive: true });
|
||||
for (const service of services) {
|
||||
await writeFile(systemdServicePath(service), systemdUnit(service));
|
||||
for (const service of plan.services) {
|
||||
await writeFile(join(systemdServiceDir, service.manager.systemdName), renderSystemdUnit(plan, service));
|
||||
}
|
||||
|
||||
const names = services.map((service) => service.systemdName);
|
||||
const names = plan.services.map((service) => service.manager.systemdName);
|
||||
run("systemctl", ["--user", "daemon-reload"], { check: true });
|
||||
run("systemctl", ["--user", "enable", ...names], { check: true });
|
||||
run("systemctl", ["--user", "restart", ...names], { check: true });
|
||||
@@ -592,8 +401,8 @@ function launchdStart(ref: ServiceRef): void {
|
||||
run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true });
|
||||
}
|
||||
|
||||
async function installLaunchdServices(services: ServiceDefinition[]): Promise<void> {
|
||||
const selected = new Set<ServiceId>(services.map((service) => service.id));
|
||||
async function installLaunchdServices(plan: NativeServicePlan): Promise<void> {
|
||||
const selected = new Set<ServiceId>(plan.services.map((service) => service.id));
|
||||
|
||||
await mkdir(launchdServiceDir, { recursive: true });
|
||||
await mkdir(logDir, { recursive: true });
|
||||
@@ -604,16 +413,21 @@ async function installLaunchdServices(services: ServiceDefinition[]): Promise<vo
|
||||
await rm(launchdPlistPath(ref), { force: true });
|
||||
}
|
||||
|
||||
for (const service of services) {
|
||||
await writeFile(launchdPlistPath(service), launchdPlist(service));
|
||||
for (const service of plan.services) {
|
||||
const plistPath = join(launchdServiceDir, service.manager.launchdPlistName);
|
||||
await writeFile(plistPath, renderLaunchdPlist(plan, service, logDir));
|
||||
}
|
||||
|
||||
for (const service of services) launchdStart(service);
|
||||
for (const service of plan.services) launchdStart(serviceRefFromPlan(service.id, service.manager));
|
||||
}
|
||||
|
||||
async function installNativeServices(backend: ServiceBackend, services: ServiceDefinition[]): Promise<void> {
|
||||
if (backend.kind === "systemd") await installSystemdServices(services);
|
||||
else await installLaunchdServices(services);
|
||||
async function installNativeServices(plan: NativeServicePlan): Promise<void> {
|
||||
if (plan.backend.kind === "systemd") await installSystemdServices(plan);
|
||||
else await installLaunchdServices(plan);
|
||||
}
|
||||
|
||||
function serviceRefFromPlan(id: ServiceId, manager: NativeServiceManagerRef): ServiceRef {
|
||||
return { id, ...manager };
|
||||
}
|
||||
|
||||
async function uninstallSystemdServices(): Promise<void> {
|
||||
@@ -705,6 +519,16 @@ function parseLaunchdField(output: string, field: string): string | undefined {
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
export function launchdRuntimeDetails(output: string): { state: string; detail: string; pid: string | undefined } {
|
||||
const state = parseLaunchdField(output, "state") ?? "unknown";
|
||||
const pid = parseLaunchdField(output, "pid");
|
||||
const lastExitCode = parseLaunchdField(output, "last exit code");
|
||||
const detail = state === "running"
|
||||
? "running"
|
||||
: lastExitCode === undefined ? state : `${state} (last exit code ${lastExitCode})`;
|
||||
return { state, detail, pid };
|
||||
}
|
||||
|
||||
function launchdRuntimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus {
|
||||
const target = launchdServiceTarget(ref);
|
||||
const filePath = serviceFilePath(backend, ref);
|
||||
@@ -715,10 +539,9 @@ function launchdRuntimeStatus(backend: ServiceBackend, ref: ServiceRef): Service
|
||||
return makeServiceRuntimeStatus(ref, "stopped", firstOutputLine(result.stderr, result.stdout) ?? "not loaded", target, filePath);
|
||||
}
|
||||
|
||||
const state = parseLaunchdField(result.stdout, "state") ?? "unknown";
|
||||
const pid = parseLaunchdField(result.stdout, "pid");
|
||||
const health: ServiceHealth = state === "running" ? "running" : state === "unknown" ? "unknown" : "stopped";
|
||||
return makeServiceRuntimeStatus(ref, health, state === "running" ? "running" : state, target, filePath, pid);
|
||||
const details = launchdRuntimeDetails(result.stdout);
|
||||
const health: ServiceHealth = details.state === "running" ? "running" : details.state === "unknown" ? "unknown" : "stopped";
|
||||
return makeServiceRuntimeStatus(ref, health, details.detail, target, filePath, details.pid);
|
||||
}
|
||||
|
||||
function runtimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus {
|
||||
@@ -747,40 +570,86 @@ function printServiceStatusReport(backend: ServiceBackend): boolean {
|
||||
return statuses.every((status) => status.health === "running");
|
||||
}
|
||||
|
||||
function backendAvailabilityChecks(backend: ServiceBackend): Check[] {
|
||||
if (backend.kind === "systemd") return [["systemctl --user", ["systemctl", "--user", "--version"]]];
|
||||
return [[`launchctl ${launchdDomain()}`, ["launchctl", "print", launchdDomain()]]];
|
||||
function configuredServiceCommand(name: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC"): string | undefined {
|
||||
const value = process.env[name];
|
||||
return value === undefined || value.trim() === "" ? undefined : value;
|
||||
}
|
||||
|
||||
function baseShellChecks(backend: ServiceBackend): Check[] {
|
||||
const shell = serviceShellLabel();
|
||||
const checks: Check[] = [[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())]];
|
||||
if (backend.kind === "systemd") checks.push([`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())]);
|
||||
return checks;
|
||||
function productionNativeServicePlanInput(
|
||||
backend: ServiceBackend,
|
||||
shell: NativeServiceShell,
|
||||
environment: Readonly<Record<string, string>>,
|
||||
): ProductionNativeServicePlanInput {
|
||||
return {
|
||||
backend,
|
||||
shell,
|
||||
environment,
|
||||
executables: {
|
||||
sessiond: {
|
||||
configuredCommand: configuredServiceCommand("PI_WEB_SESSIOND_EXEC"),
|
||||
namedCommand: "pi-web-sessiond",
|
||||
bundledEntrypointPath: packageEntrypointPath("sessiond"),
|
||||
},
|
||||
web: {
|
||||
configuredCommand: configuredServiceCommand("PI_WEB_SERVER_EXEC"),
|
||||
namedCommand: "pi-web-server",
|
||||
bundledEntrypointPath: packageEntrypointPath("server"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function devInstallChecks(backend: ServiceBackend, root: string): Check[] {
|
||||
const shell = serviceShellLabel();
|
||||
const checks: Check[] = [
|
||||
[`${shell} can find npm`, serviceShellCommand(commandCheck("npm"), root)],
|
||||
[`${shell} can find bash`, serviceShellCommand(commandCheck("bash"), root)],
|
||||
];
|
||||
if (backend.kind === "systemd") {
|
||||
checks.push(
|
||||
[`systemd user ${shell} can find npm`, systemdUserServiceShellCommand(commandCheck("npm"), root)],
|
||||
[`systemd user ${shell} can find bash`, systemdUserServiceShellCommand(commandCheck("bash"), root)],
|
||||
);
|
||||
function nativeServiceInstallCandidate(
|
||||
options: InstallOptions,
|
||||
backend: ServiceBackend,
|
||||
configPath: string,
|
||||
devRoot: string | undefined,
|
||||
): NativeServiceInstallCandidate {
|
||||
const shell = detectServiceShell();
|
||||
const environment = configEnvironment(options, configPath);
|
||||
if (options.mode === "production") {
|
||||
return {
|
||||
mode: "production",
|
||||
input: productionNativeServicePlanInput(backend, shell, environment),
|
||||
};
|
||||
}
|
||||
return checks;
|
||||
|
||||
const root = devRoot ?? devRootPath();
|
||||
return {
|
||||
mode: "development",
|
||||
input: {
|
||||
backend,
|
||||
shell,
|
||||
environment,
|
||||
workingDirectory: root,
|
||||
packageJsonPath: join(root, "package.json"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installPreflightChecks(backend: ServiceBackend, mode: InstallMode, executables: ServiceExecutables | undefined, devRoot: string | undefined): Check[] {
|
||||
return [
|
||||
...backendAvailabilityChecks(backend),
|
||||
...baseShellChecks(backend),
|
||||
...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []),
|
||||
...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []),
|
||||
];
|
||||
function printNativeServiceInstallFailure(failure: NativeServiceInstallFailure): void {
|
||||
if (failure.kind === "plan-resolution") {
|
||||
for (const item of failure.failures) {
|
||||
if (item.kind === "probe-infrastructure") {
|
||||
console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
|
||||
} else if (item.kind === "entrypoint-inspection-failure") {
|
||||
console.log(`✗ Could not inspect bundled ${item.serviceId} entrypoint ${item.entrypointPath}: ${item.message}`);
|
||||
} else {
|
||||
console.log(`✗ ${item.namedCommand} is unavailable to the service manager, and bundled entrypoint ${item.bundledEntrypointPath} is missing.`);
|
||||
if (item.namedCommandFailure !== null) console.log(` ${item.namedCommandFailure}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of failure.failures) {
|
||||
if (item.kind === "probe-infrastructure") {
|
||||
console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
|
||||
} else {
|
||||
console.log(`✗ ${item.prerequisite.description}`);
|
||||
if (item.detail !== null && item.detail !== item.prerequisite.description) console.log(` ${item.detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function install(args: string[]): Promise<void> {
|
||||
@@ -788,22 +657,26 @@ async function install(args: string[]): Promise<void> {
|
||||
const options = parseInstallOptions(args);
|
||||
const devRoot = options.mode === "dev" ? devRootPath() : undefined;
|
||||
if (devRoot !== undefined) validateDevCheckout(devRoot);
|
||||
const configPath = installConfigPath(options);
|
||||
const candidate = nativeServiceInstallCandidate(options, backend, configPath, devRoot);
|
||||
|
||||
const executables = options.mode === "production" ? resolveServiceExecutables(backend) : undefined;
|
||||
console.log(`Running PI WEB ${options.mode} install preflight checks...`);
|
||||
console.log(`Service backend: ${backend.label}`);
|
||||
console.log(`Service shell: ${describeServiceShell()}`);
|
||||
if (!runChecks(installPreflightChecks(backend, options.mode, executables, devRoot))) {
|
||||
printPathSetupAdvice();
|
||||
throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail.");
|
||||
const result = await installNativeServiceCandidate(candidate, {
|
||||
probe: createNativeServiceAuthoritativeProbe(),
|
||||
fileExists: regularFileExists,
|
||||
writeInitialConfig: () => writeInitialConfig(options, configPath),
|
||||
replaceServices: installNativeServices,
|
||||
});
|
||||
if (!result.ok) {
|
||||
printNativeServiceInstallFailure(result.failure);
|
||||
if (nativeServiceInstallFailureNeedsPathAdvice(result.failure)) printPathSetupAdvice();
|
||||
throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail.");
|
||||
}
|
||||
for (const service of result.plan.services.filter((item) => item.strategy.kind === "configured-override")) {
|
||||
console.log(`! ${service.description} uses a configured command override; preflight did not execute that arbitrary command.`);
|
||||
}
|
||||
|
||||
const configPath = await writeInitialConfig(options);
|
||||
const services = options.mode === "dev"
|
||||
? devServiceDefinitions(options, configPath, devRoot ?? devRootPath())
|
||||
: productionServiceDefinitions(options, configPath, executables ?? resolveServiceExecutables(backend));
|
||||
|
||||
await installNativeServices(backend, services);
|
||||
|
||||
console.log(`\nPI WEB ${options.mode} services are installed and starting.`);
|
||||
console.log(`Config: ${configPath}`);
|
||||
@@ -886,29 +759,13 @@ function serviceShellLabel(): string {
|
||||
return `${detectServiceShell().name} -lc`;
|
||||
}
|
||||
|
||||
function systemdUserServiceShellCommand(command: string, cwd?: string): string[] {
|
||||
return [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--wait",
|
||||
"--collect",
|
||||
"--pipe",
|
||||
"--quiet",
|
||||
...serviceShellCommand(command, cwd),
|
||||
];
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function commandCheck(command: string): string {
|
||||
return `command -v ${shellQuote(command)}`;
|
||||
return `command -v ${serviceShellQuote(command)}`;
|
||||
}
|
||||
|
||||
export function commandWithVersionCheck(command: string): string {
|
||||
const found = commandCheck(command);
|
||||
const commandWord = shellQuote(command);
|
||||
const commandWord = serviceShellQuote(command);
|
||||
if (detectServiceShell().name === "fish") {
|
||||
return `${found} && begin; ${commandWord} --version 2>&1 || true; end`;
|
||||
}
|
||||
@@ -926,30 +783,14 @@ export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): str
|
||||
return effectiveAgentConfig(env, effectivePiWebConfig({ env }).config).command;
|
||||
}
|
||||
|
||||
function doctorChecks(): Check[] {
|
||||
function generalDoctorChecks(): Check[] {
|
||||
const shell = serviceShellLabel();
|
||||
const backend = currentServiceBackend();
|
||||
const agentCommand = agentCommandForChecks();
|
||||
if (backend === undefined) {
|
||||
return [
|
||||
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
|
||||
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||
[`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||
];
|
||||
}
|
||||
|
||||
const checks: Check[] = [
|
||||
...backendAvailabilityChecks(backend),
|
||||
...baseShellChecks(backend),
|
||||
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||
[`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||
return [
|
||||
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
|
||||
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||
];
|
||||
const executables = resolveServiceExecutables(backend);
|
||||
checks.push(...executables.web.checks, ...executables.sessiond.checks);
|
||||
if (backend.kind === "systemd") {
|
||||
checks.push([`systemd user ${shell} can find ${agentCommand}`, systemdUserServiceShellCommand(commandWithVersionCheck(agentCommand))]);
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
function runChecks(checks: Check[]): boolean {
|
||||
@@ -976,10 +817,7 @@ function printCheckOutput(output: string): void {
|
||||
|
||||
function optionalDoctorChecks(): Check[] {
|
||||
const shell = serviceShellLabel();
|
||||
const backend = currentServiceBackend();
|
||||
const checks: Check[] = [[`${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
|
||||
if (backend?.kind === "systemd") checks.push([`systemd user ${shell} can find optional ripgrep (rg)`, systemdUserServiceShellCommand(commandCheck("rg"))]);
|
||||
return checks;
|
||||
return [[`Caller login ${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
|
||||
}
|
||||
|
||||
function printOptionalDoctorChecks(): void {
|
||||
@@ -999,8 +837,115 @@ function printOptionalDoctorChecks(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function printPathSetupAdvice(): void {
|
||||
const shell = detectServiceShell();
|
||||
function installedServiceDefinitions(
|
||||
backend: ServiceBackend,
|
||||
ids: readonly ServiceId[],
|
||||
): InstalledNativeServiceDefinition[] {
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
contents: readFileSync(serviceFilePath(backend, serviceRefs[id]), "utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
function nativeServiceDoctorTarget(backend: ServiceBackend): NativeServiceDoctorTarget {
|
||||
const ids = installedServiceIds(backend);
|
||||
const mode = inferInstalledNativeServiceMode(ids);
|
||||
if (mode === "ambiguous") {
|
||||
return {
|
||||
kind: "inspection-failure",
|
||||
message: `installed service IDs do not identify one mode (${[...ids].join(", ") || "none"}).`,
|
||||
};
|
||||
}
|
||||
if (mode === "none") {
|
||||
return {
|
||||
kind: "prospective-production",
|
||||
input: productionNativeServicePlanInput(backend, detectServiceShell(), {}),
|
||||
reason: "no installed service strategy is available",
|
||||
};
|
||||
}
|
||||
const expectedIds = mode === "production"
|
||||
? productionNativeServiceIds
|
||||
: (["sessiond", "uiDev"] as const);
|
||||
const missingId = expectedIds.find((id) => !ids.has(id));
|
||||
if (missingId !== undefined) {
|
||||
return {
|
||||
kind: "inspection-failure",
|
||||
message: `installed ${mode} service set is incomplete; ${missingId} is missing.`,
|
||||
};
|
||||
}
|
||||
|
||||
let definitions: InstalledNativeServiceDefinition[];
|
||||
try {
|
||||
definitions = installedServiceDefinitions(
|
||||
backend,
|
||||
expectedIds,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
kind: "inspection-failure",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "development") {
|
||||
const inspection = inspectInstalledDevelopmentServiceInput(backend, definitions);
|
||||
return inspection.ok
|
||||
? { kind: "installed-development", input: inspection.value }
|
||||
: { kind: "inspection-failure", message: inspection.message };
|
||||
}
|
||||
|
||||
const inspection = inspectInstalledProductionServiceContext(backend, definitions);
|
||||
return inspection.ok
|
||||
? {
|
||||
kind: "prospective-production",
|
||||
input: productionNativeServicePlanInput(backend, inspection.value.shell, inspection.value.environment),
|
||||
reason: "installed executable strategy is not recorded",
|
||||
}
|
||||
: { kind: "inspection-failure", message: inspection.message };
|
||||
}
|
||||
|
||||
async function printNativeServiceDoctorChecks(backend: ServiceBackend): Promise<NativeServiceDoctorReport> {
|
||||
const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), {
|
||||
probe: createNativeServiceAuthoritativeProbe(),
|
||||
fileExists: regularFileExists,
|
||||
});
|
||||
const report = formatNativeServiceDoctorResult(result);
|
||||
for (const line of report.lines) console.log(line);
|
||||
printCallerContextComparisons(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
function printCallerContextComparisons(report: NativeServiceDoctorReport): void {
|
||||
if (report.plan === null || report.failedPrerequisites.length === 0) return;
|
||||
const seen = new Set<string>();
|
||||
for (const prerequisite of report.failedPrerequisites) {
|
||||
if (seen.has(prerequisite.id)) continue;
|
||||
seen.add(prerequisite.id);
|
||||
const service = report.plan.services.find((candidate) => candidate.prerequisites.some((item) => item.id === prerequisite.id));
|
||||
const command = nativeServicePrerequisiteShellCheck(report.plan.shell.name, prerequisite);
|
||||
const result = captureServiceShell(report.plan.shell, command, service?.workingDirectory ?? null);
|
||||
console.log(
|
||||
` Caller-invoked ${report.plan.shell.name} -lc ${result.status === 0 ? "satisfies" : "also does not satisfy"} ${prerequisite.description}; the service-manager result is authoritative.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function captureServiceShell(
|
||||
shell: NativeServiceShell,
|
||||
command: string,
|
||||
workingDirectory: string | null,
|
||||
): { status: number; stdout: string; stderr: string } {
|
||||
const fullCommand = workingDirectory === null
|
||||
? command
|
||||
: `cd ${shellQuoteFor(shell.name, workingDirectory)} && ${command}`;
|
||||
return capture("/usr/bin/env", [shell.executable, "-lc", fullCommand]);
|
||||
}
|
||||
|
||||
function shellQuoteFor(shell: NativeServiceShell["name"], value: string): string {
|
||||
return shell === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
|
||||
}
|
||||
|
||||
function printPathSetupAdvice(shell: NativeServiceShell = detectServiceShell()): void {
|
||||
console.log("\nPATH setup advice:");
|
||||
if (shell.name === "bash") {
|
||||
console.log(" Detected bash. Put PATH setup for node/version managers/tools in ~/.bash_profile or ~/.profile.");
|
||||
@@ -1015,21 +960,36 @@ function printPathSetupAdvice(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function doctorExitCode(
|
||||
generalReadinessOk: boolean,
|
||||
nativeServicePlanOk: boolean,
|
||||
nodePtySpawnHelperOk: boolean,
|
||||
): 0 | 1 {
|
||||
return generalReadinessOk && nativeServicePlanOk && nodePtySpawnHelperOk ? 0 : 1;
|
||||
}
|
||||
|
||||
async function doctor(): Promise<void> {
|
||||
const backend = currentServiceBackend();
|
||||
console.log(`Platform: ${platformLabel()}`);
|
||||
console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
|
||||
console.log(`Service shell: ${describeServiceShell()}`);
|
||||
if (backend === undefined) {
|
||||
console.log(`- Native user service checks skipped on ${platformLabel()}`);
|
||||
console.log(`- Native user service plan checks skipped on ${platformLabel()}; no native-service drift is reported.`);
|
||||
}
|
||||
console.log("");
|
||||
await printPiWebVersionReport();
|
||||
console.log("\nDoctor checks:");
|
||||
const ok = runChecks(doctorChecks());
|
||||
|
||||
console.log("\nGeneral login-shell readiness (separate from native-service requirements):");
|
||||
const generalReadinessOk = runChecks(generalDoctorChecks());
|
||||
printOptionalDoctorChecks();
|
||||
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
|
||||
|
||||
let nativeServiceReport: NativeServiceDoctorReport | null = null;
|
||||
if (backend !== undefined) {
|
||||
console.log("\nNative service plan checks (service-manager context):");
|
||||
nativeServiceReport = await printNativeServiceDoctorChecks(backend);
|
||||
}
|
||||
|
||||
if (supportsSystemdUserServices()) {
|
||||
const linger = isLingerEnabled();
|
||||
if (linger === true) {
|
||||
@@ -1047,17 +1007,21 @@ async function doctor(): Promise<void> {
|
||||
console.log(`- systemd user lingering skipped on ${platformLabel()}`);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
console.log("\nIf a command works in your terminal but fails here, make sure your service shell login files set PATH the same way.");
|
||||
if (backend?.kind === "systemd") console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
|
||||
printPathSetupAdvice();
|
||||
const nativeServicePlanOk = nativeServiceReport?.ok ?? true;
|
||||
const pathFailure = !generalReadinessOk || nativeServiceReport?.pathAdviceRecommended === true;
|
||||
if (pathFailure) {
|
||||
console.log("\nIf a command works in your terminal but fails in the service-manager check, compare the caller and manager contexts above.");
|
||||
const adviceShell = nativeServiceReport?.pathAdviceRecommended === true && nativeServiceReport.adviceShell !== null
|
||||
? nativeServiceReport.adviceShell
|
||||
: detectServiceShell();
|
||||
printPathSetupAdvice(adviceShell);
|
||||
}
|
||||
|
||||
if (ok && backend === undefined) {
|
||||
if (generalReadinessOk && backend === undefined) {
|
||||
console.log(`\n${manualRunAdvice()}`);
|
||||
}
|
||||
|
||||
if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1;
|
||||
if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtySpawnHelperOk) !== 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function printNodePtyDarwinSpawnHelperCheck(): boolean {
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>PI WEB</title>
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
|
||||
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--pi-control-font-size: 16px;
|
||||
--pi-control-font-family: system-ui, sans-serif;
|
||||
--pi-control-monospace-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--pi-bg: #0d1117;
|
||||
--pi-surface: #161b22;
|
||||
--pi-surface-hover: #21262d;
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"name": "PI WEB",
|
||||
"short_name": "PI WEB",
|
||||
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icon-192.png",
|
||||
"src": "./pwa-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-icon-512.png",
|
||||
"src": "./pwa-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -13,6 +13,20 @@ const workspace: Workspace = {
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
function piWebStatusResponse() {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
const commandRun: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
@@ -26,28 +40,42 @@ const commandRun: TerminalCommandRun = {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("machine-scoped runtime API", () => {
|
||||
it("reads machine PI WEB status through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
});
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.piWebStatus("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the local status route", async () => {
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.checkForUpdates();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the selected machine route", async () => {
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.checkForUpdates("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("reads machine runtime through the gateway route", async () => {
|
||||
@@ -56,7 +84,109 @@ describe("machine-scoped runtime API", () => {
|
||||
await machinesApi.runtime("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings config and plugin APIs", () => {
|
||||
it("preserves gateway config and plugin routes by default", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(piWebConfigResponse({ host: "127.0.0.1" })),
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
|
||||
jsonResponse(piWebPluginsResponse()),
|
||||
]);
|
||||
|
||||
await expect(configApi.config()).resolves.toMatchObject({ config: { host: "127.0.0.1" } });
|
||||
await expect(configApi.saveConfig({ spawnSessions: true })).resolves.toMatchObject({ config: { spawnSessions: true } });
|
||||
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"https://pi.example.test/api/config",
|
||||
"https://pi.example.test/api/config",
|
||||
"https://pi.example.test/api/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
});
|
||||
|
||||
it("uses machine-scoped config and plugin routes when a machine id is provided", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: false })),
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
|
||||
jsonResponse(piWebPluginsResponse()),
|
||||
]);
|
||||
|
||||
await expect(configApi.config("remote a")).resolves.toMatchObject({ config: { spawnSessions: false } });
|
||||
await expect(configApi.saveConfig({ spawnSessions: true }, "remote a")).resolves.toMatchObject({ config: { spawnSessions: true } });
|
||||
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"https://pi.example.test/api/machines/remote%20a/config",
|
||||
"https://pi.example.test/api/machines/remote%20a/config",
|
||||
"https://pi.example.test/api/machines/remote%20a/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi package API", () => {
|
||||
it("preserves the legacy local Pi package-management routes by default", async () => {
|
||||
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
|
||||
jsonResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages }),
|
||||
jsonResponse({ action: "update", source: "npm:@acme/tools", packages }),
|
||||
jsonResponse({ action: "update", packages }),
|
||||
]);
|
||||
|
||||
await expect(piPackagesApi.packages()).resolves.toEqual({ packages });
|
||||
await piPackagesApi.install("npm:@acme/new-tools");
|
||||
await piPackagesApi.remove("../project-tools", "project");
|
||||
await piPackagesApi.update("npm:@acme/tools");
|
||||
await piPackagesApi.update();
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"https://pi.example.test/api/pi-packages",
|
||||
"https://pi.example.test/api/pi-packages/install",
|
||||
"https://pi.example.test/api/pi-packages/remove",
|
||||
"https://pi.example.test/api/pi-packages/update",
|
||||
"https://pi.example.test/api/pi-packages/update",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "../project-tools", scope: "project" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
|
||||
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses machine-scoped Pi package-management routes when a machine id is provided", async () => {
|
||||
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
|
||||
jsonResponse({ action: "remove", source: "../project-tools", removed: true, packages }),
|
||||
jsonResponse({ action: "update", packages }),
|
||||
]);
|
||||
|
||||
await expect(piPackagesApi.packages("local")).resolves.toEqual({ packages });
|
||||
await expect(piPackagesApi.packages("remote a")).resolves.toEqual({ packages });
|
||||
await piPackagesApi.install("npm:@acme/new-tools", "remote a");
|
||||
await piPackagesApi.remove("../project-tools", undefined, "remote a");
|
||||
await piPackagesApi.update(undefined, "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"https://pi.example.test/api/machines/local/pi-packages",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/install",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/remove",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/update",
|
||||
]);
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" });
|
||||
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,14 +200,31 @@ describe("session API compatibility", () => {
|
||||
await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/cleanup/preview");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null });
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup");
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/cleanup");
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
|
||||
});
|
||||
|
||||
it("posts bulk session mutation requests through the selected machine", async () => {
|
||||
const archived = { archived: true, archivedSessionIds: ["s 1"], failures: [{ sessionId: "s 2", error: "busy" }], generatedAt: "now" };
|
||||
const deleted = { deleted: true, deletedSessionIds: ["s 1"], failures: [], generatedAt: "later" };
|
||||
const fetchMock = stubSequenceFetch([jsonResponse(archived), jsonResponse(deleted)]);
|
||||
|
||||
await expect(sessionsApi.archiveMany([{ id: "s 1", cwd: "/repo" }, "s 2"], "remote a")).resolves.toEqual(archived);
|
||||
await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/bulk/archive");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] });
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions/bulk/delete-archived");
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
||||
});
|
||||
|
||||
it("keeps legacy session-id calls free of cwd context", async () => {
|
||||
const fetchMock = stubJsonFetch({ accepted: true });
|
||||
|
||||
@@ -85,7 +232,7 @@ describe("session API compatibility", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" });
|
||||
});
|
||||
|
||||
@@ -96,7 +243,7 @@ describe("session API compatibility", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
|
||||
});
|
||||
});
|
||||
@@ -108,7 +255,7 @@ describe("machine-scoped file suggestion API", () => {
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
});
|
||||
|
||||
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
|
||||
@@ -117,7 +264,18 @@ describe("machine-scoped file suggestion API", () => {
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped workspace API", () => {
|
||||
it("keeps project ids in one encoded route segment when listing workspaces", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await workspacesApi.workspaces("../p /?", "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/..%2Fp%20%2F%3F/workspaces");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +287,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@@ -140,7 +298,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||
});
|
||||
@@ -152,7 +310,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@@ -168,9 +326,9 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
await terminalsApi.cancelCommandRun("run 1", "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
|
||||
});
|
||||
@@ -180,7 +338,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote-a/terminal-command-runs/missing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,7 +350,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
|
||||
});
|
||||
@@ -205,7 +363,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
|
||||
});
|
||||
@@ -243,7 +401,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url] = fetchCall(fetchMock, 0);
|
||||
expect(url).toContain("/api/machines/remote%20a/");
|
||||
expect(url).toContain("api/machines/remote%20a/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +439,20 @@ function requestBody(init: RequestInit | undefined): string {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
function piWebPluginsResponse() {
|
||||
return { plugins: [{ id: "info", module: "/pi-web-plugins/info/plugin.js", source: "test", scope: "local", machineSpecific: false, enabled: true }] };
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
parseModelSelectionResponse,
|
||||
parseMoveWorkspaceFileResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiPackageMutationResponse,
|
||||
parsePiPackagesResponse,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
parsePiWebRuntimeResponse,
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
parseReloaded,
|
||||
parseRestored,
|
||||
parseSavedAttachments,
|
||||
parseSessionBulkArchiveResponse,
|
||||
parseSessionBulkDeleteArchivedResponse,
|
||||
parseSessionCleanupExecuteResponse,
|
||||
parseSessionCleanupPreviewResponse,
|
||||
parseSessionInfo,
|
||||
@@ -45,9 +50,9 @@ import {
|
||||
parseWorkspace,
|
||||
parseWorkspaceActivityResponse,
|
||||
} from "./parsers";
|
||||
import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||
import { machineGitDiffPath, messagePath } from "./urls";
|
||||
|
||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
const machinePrefix = (machineId = "local") => `api/machines/${encodeURIComponent(machineId)}`;
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -59,20 +64,20 @@ function sessionCwd(session: SessionLookup): string | undefined {
|
||||
return typeof session === "string" ? undefined : session.cwd;
|
||||
}
|
||||
|
||||
function sessionBaseUrl(session: SessionLookup, machineId = "local"): string {
|
||||
function sessionBasePath(session: SessionLookup, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId(session))}`;
|
||||
}
|
||||
|
||||
function sessionUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionBaseUrl(session, machineId)}/${endpoint}`;
|
||||
function sessionPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionBasePath(session, machineId)}/${endpoint}`;
|
||||
}
|
||||
|
||||
function sessionQueryUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionUrl(session, endpoint, machineId)}${sessionQuery(session)}`;
|
||||
function sessionQueryPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionPath(session, endpoint, machineId)}${sessionQuery(session)}`;
|
||||
}
|
||||
|
||||
function sessionBaseQueryUrl(session: SessionLookup, machineId = "local"): string {
|
||||
return `${sessionBaseUrl(session, machineId)}${sessionQuery(session)}`;
|
||||
function sessionBaseQueryPath(session: SessionLookup, machineId = "local"): string {
|
||||
return `${sessionBasePath(session, machineId)}${sessionQuery(session)}`;
|
||||
}
|
||||
|
||||
function sessionQuery(session: SessionLookup): string {
|
||||
@@ -85,26 +90,70 @@ function sessionBody(session: SessionLookup, fields: Record<string, unknown> = {
|
||||
return JSON.stringify(cwd === undefined || cwd === "" ? fields : { cwd, ...fields });
|
||||
}
|
||||
|
||||
function sessionBulkMutationBody(sessions: readonly SessionLookup[]): string {
|
||||
return JSON.stringify({ sessions: sessions.map(sessionBulkMutationRef) });
|
||||
}
|
||||
|
||||
function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef {
|
||||
const id = sessionId(session);
|
||||
const cwd = sessionCwd(session);
|
||||
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
|
||||
}
|
||||
|
||||
function piWebStatusPath(machineId: string): string {
|
||||
return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
|
||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
piWebStatus: (machineId = "local") => request(piWebStatusPath(machineId), parsePiWebStatusResponse),
|
||||
checkForUpdates: (machineId = "local") => request(`${piWebStatusPath(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
|
||||
piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
};
|
||||
|
||||
export const machinesApi = {
|
||||
machines: () => request("/api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
machines: () => request("api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
};
|
||||
|
||||
function configPath(machineId?: string): string {
|
||||
return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`;
|
||||
}
|
||||
|
||||
function pluginsPath(machineId?: string): string {
|
||||
return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`;
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
config: () => request("/api/config", parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
config: (machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
};
|
||||
|
||||
export const pluginsApi = {
|
||||
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
|
||||
plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse),
|
||||
};
|
||||
|
||||
function piPackagePath(endpoint = "", machineId?: string): string {
|
||||
const basePath = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
|
||||
return endpoint === "" ? basePath : `${basePath}/${endpoint}`;
|
||||
}
|
||||
|
||||
export const piPackagesApi = {
|
||||
packages: (machineId?: string) => request(piPackagePath("", machineId), parsePiPackagesResponse),
|
||||
install: (source: string, machineId?: string) => {
|
||||
const body: PiPackageInstallRequest = { source };
|
||||
return request(piPackagePath("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
remove: (source: string, scope?: PiPackageScope, machineId?: string) => {
|
||||
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
|
||||
return request(piPackagePath("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
update: (source?: string, machineId?: string) => {
|
||||
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
|
||||
return request(piPackagePath("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
},
|
||||
};
|
||||
|
||||
export const activityApi = {
|
||||
@@ -119,7 +168,7 @@ export const projectsApi = {
|
||||
};
|
||||
|
||||
export const workspacesApi = {
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces`, arrayOf(parseWorkspace)),
|
||||
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
@@ -156,28 +205,30 @@ export const sessionsApi = {
|
||||
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
|
||||
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
|
||||
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionPath(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
|
||||
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionPath(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
|
||||
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionPath(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionPath(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
restore: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryPath(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||
@@ -206,7 +257,7 @@ export const terminalsApi = {
|
||||
};
|
||||
|
||||
async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise<TerminalCommandRun | undefined> {
|
||||
const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||
const response = await fetch(resolveAppUrl(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`));
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
@@ -263,7 +314,7 @@ export const filesApi = {
|
||||
|
||||
export const gitApi = {
|
||||
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffPath(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||
};
|
||||
|
||||
export const api = {
|
||||
@@ -271,6 +322,7 @@ export const api = {
|
||||
...machinesApi,
|
||||
...configApi,
|
||||
...pluginsApi,
|
||||
...piPackagesApi,
|
||||
...activityApi,
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
|
||||
@@ -17,6 +17,10 @@ const workspace: Workspace = {
|
||||
};
|
||||
const session = { id: "s 1", cwd: workspace.path };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -28,6 +32,14 @@ describe("federated route contract", () => {
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(piWebApi.checkForUpdates(machineId)),
|
||||
ignoreParseFailure(configApi.config(machineId)),
|
||||
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
|
||||
ignoreParseFailure(pluginsApi.plugins(machineId)),
|
||||
ignoreParseFailure(piPackagesApi.packages(machineId)),
|
||||
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
|
||||
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
|
||||
ignoreParseFailure(piPackagesApi.update("npm:@acme/tools", machineId)),
|
||||
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||
@@ -48,6 +60,8 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveMany([session], machineId)),
|
||||
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
|
||||
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.status(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
||||
@@ -58,6 +72,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.commands(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)),
|
||||
ignoreParseFailure(sessionsApi.saveAttachments(session, [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }], machineId, "uploads")),
|
||||
ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)),
|
||||
ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)),
|
||||
@@ -102,7 +117,6 @@ describe("federated route contract", () => {
|
||||
webSocketUrls.push(url);
|
||||
}
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
|
||||
sessionEvents(session, machineId);
|
||||
globalSessionEvents(machineId);
|
||||
@@ -135,8 +149,9 @@ function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string):
|
||||
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
|
||||
const url = toUrl(input);
|
||||
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefix.length) || "/" };
|
||||
const prefixIndex = url.pathname.lastIndexOf(prefix);
|
||||
if (prefixIndex === -1) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefixIndex + prefix.length) || "/" };
|
||||
}
|
||||
|
||||
function toUrl(input: string | URL | Request): URL {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
|
||||
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
const response = await fetch(resolveAppUrl(url), { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
throw new Error(errorMessage(body) ?? response.statusText);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("parses PI WEB config responses", () => {
|
||||
@@ -24,11 +24,60 @@ describe("API parsers", () => {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
||||
});
|
||||
|
||||
it("parses Pi package list and mutation responses", () => {
|
||||
const packages = [
|
||||
{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
];
|
||||
|
||||
expect(parsePiPackagesResponse({ packages })).toEqual({ packages });
|
||||
expect(parsePiPackageMutationResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages })).toEqual({
|
||||
action: "remove",
|
||||
source: "../project-tools",
|
||||
scope: "project",
|
||||
removed: true,
|
||||
packages,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed Pi package responses", () => {
|
||||
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "global", filtered: false }] })).toThrow("Invalid Pi package scope");
|
||||
expect(() => parsePiPackageMutationResponse({ action: "sync", packages: [] })).toThrow("Invalid Pi package mutation action");
|
||||
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
|
||||
});
|
||||
|
||||
it("parses Docker PI WEB installation metadata", () => {
|
||||
const response = {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", dockerMode: "dev" } },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { restart: "pi-web-docker restart", status: "pi-web-docker status" },
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const parsed = parsePiWebStatusResponse(response);
|
||||
|
||||
expect(parsed.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(parsed.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "dev" });
|
||||
expect(parsed.commands).toEqual({ restart: "pi-web-docker restart", status: "pi-web-docker status" });
|
||||
expect(() => parsePiWebStatusResponse({
|
||||
...response,
|
||||
components: {
|
||||
...response.components,
|
||||
web: { ...response.components.web, installation: { kind: "docker", dockerMode: "hidden" } },
|
||||
},
|
||||
})).toThrow("Invalid PI WEB Docker mode");
|
||||
});
|
||||
|
||||
it("parses PI WEB plugin status responses", () => {
|
||||
@@ -69,9 +118,56 @@ describe("API parsers", () => {
|
||||
expect(() => parseSessionCleanupExecuteResponse({ generatedAt: "now", thresholds: {}, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: ["s1"], deletedSessionIds: [1] })).toThrow("Expected string array field: deletedSessionIds");
|
||||
});
|
||||
|
||||
it("parses bulk session mutation responses", () => {
|
||||
const failure = { sessionId: "busy", error: "Session is busy" };
|
||||
expect(parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [failure], generatedAt: "now" })).toEqual({
|
||||
archived: true,
|
||||
archivedSessionIds: ["s1"],
|
||||
failures: [failure],
|
||||
generatedAt: "now",
|
||||
});
|
||||
expect(parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: ["s2"], failures: [], generatedAt: "later" })).toEqual({
|
||||
deleted: true,
|
||||
deletedSessionIds: ["s2"],
|
||||
failures: [],
|
||||
generatedAt: "later",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed bulk session mutation responses", () => {
|
||||
expect(() => parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [{ sessionId: "s2" }], generatedAt: "now" })).toThrow("Expected string field: error");
|
||||
expect(() => parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: [1], failures: [], generatedAt: "now" })).toThrow("Expected string array field: deletedSessionIds");
|
||||
});
|
||||
|
||||
it("parses session info including optional persistence signals", () => {
|
||||
expect(parseSessionInfo({
|
||||
id: "s1",
|
||||
path: "/sessions/s1.jsonl",
|
||||
cwd: "/repo",
|
||||
persisted: false,
|
||||
name: "Draft session",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
})).toEqual({
|
||||
id: "s1",
|
||||
path: "/sessions/s1.jsonl",
|
||||
cwd: "/repo",
|
||||
persisted: false,
|
||||
name: "Draft session",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
});
|
||||
expect(() => parseSessionInfo({ id: "s1", path: "", cwd: "/repo", persisted: "yes", created: "now", modified: "now", messageCount: 0, firstMessage: "" })).toThrow("Expected optional boolean field: persisted");
|
||||
});
|
||||
|
||||
it("validates session status including optional model and nullable context usage", () => {
|
||||
expect(parseSessionStatus({
|
||||
sessionId: "s1",
|
||||
persisted: true,
|
||||
isStreaming: false,
|
||||
isCompacting: true,
|
||||
isBashRunning: false,
|
||||
@@ -85,6 +181,7 @@ describe("API parsers", () => {
|
||||
thinkingLevel: "medium",
|
||||
})).toEqual({
|
||||
sessionId: "s1",
|
||||
persisted: true,
|
||||
isStreaming: false,
|
||||
isCompacting: true,
|
||||
isBashRunning: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import { isPiWebCapability } from "../../../shared/capabilities";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -156,12 +157,14 @@ function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveC
|
||||
export function parseSessionInfo(value: unknown): SessionInfo {
|
||||
const record = requireRecord(value);
|
||||
const name = optionalString(record, "name");
|
||||
const persisted = parseOptionalBoolean(record["persisted"], "persisted");
|
||||
const parentSessionPath = optionalString(record, "parentSessionPath");
|
||||
const archivedAt = optionalString(record, "archivedAt");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
path: requireString(record, "path"),
|
||||
cwd: requireString(record, "cwd"),
|
||||
...(persisted === undefined ? {} : { persisted }),
|
||||
...(name === undefined ? {} : { name }),
|
||||
created: requireString(record, "created"),
|
||||
modified: requireString(record, "modified"),
|
||||
@@ -177,6 +180,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
sessionId: requireString(record, "sessionId"),
|
||||
...optionalField("persisted", parseOptionalBoolean(record["persisted"], "persisted")),
|
||||
isStreaming: requireBoolean(record, "isStreaming"),
|
||||
isCompacting: requireBoolean(record, "isCompacting"),
|
||||
isBashRunning: requireBoolean(record, "isBashRunning"),
|
||||
@@ -212,6 +216,33 @@ export function parseSessionCleanupExecuteResponse(value: unknown): SessionClean
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionBulkArchiveResponse(value: unknown): SessionBulkArchiveResponse {
|
||||
const record = requireRecord(value);
|
||||
if (record["archived"] !== true) throw new Error("Expected bulk archived response");
|
||||
return {
|
||||
archived: true,
|
||||
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
|
||||
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionBulkDeleteArchivedResponse(value: unknown): SessionBulkDeleteArchivedResponse {
|
||||
const record = requireRecord(value);
|
||||
if (record["deleted"] !== true) throw new Error("Expected bulk deleted response");
|
||||
return {
|
||||
deleted: true,
|
||||
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
|
||||
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionBulkFailure(value: unknown): SessionBulkFailure {
|
||||
const record = requireRecord(value);
|
||||
return { sessionId: requireString(record, "sessionId"), error: requireString(record, "error") };
|
||||
}
|
||||
|
||||
function parseSessionCleanupThresholds(value: unknown): SessionCleanupThresholds {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
@@ -620,6 +651,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
|
||||
const record = requireRecord(value);
|
||||
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
|
||||
}
|
||||
|
||||
export function parsePiPackageMutationResponse(value: unknown): PiPackageMutationResponse {
|
||||
const record = requireRecord(value);
|
||||
const source = optionalString(record, "source");
|
||||
const scope = record["scope"] === undefined ? undefined : parsePiPackageScope(record["scope"]);
|
||||
const removed = parseOptionalBoolean(record["removed"], "removed");
|
||||
return {
|
||||
action: parsePiPackageMutationAction(record["action"]),
|
||||
...optionalField("source", source),
|
||||
...optionalField("scope", scope),
|
||||
...optionalField("removed", removed),
|
||||
packages: arrayOf(parsePiPackageInfo)(record["packages"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiPackageInfo(value: unknown): PiPackageInfo {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
source: requireString(record, "source"),
|
||||
scope: parsePiPackageScope(record["scope"]),
|
||||
filtered: requireBoolean(record, "filtered"),
|
||||
...optionalField("installedPath", optionalString(record, "installedPath")),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiPackageScope(value: unknown): PiPackageScope {
|
||||
if (value !== "user" && value !== "project") throw new Error("Invalid Pi package scope");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiPackageMutationAction(value: unknown): PiPackageMutationAction {
|
||||
if (value !== "install" && value !== "remove" && value !== "update") throw new Error("Invalid Pi package mutation action");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
|
||||
const record = requireRecord(value);
|
||||
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
|
||||
@@ -710,15 +780,18 @@ function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo |
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
|
||||
const scope = record["scope"];
|
||||
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid PI WEB installation scope");
|
||||
const dockerMode = record["dockerMode"];
|
||||
if (dockerMode !== undefined && dockerMode !== "runtime" && dockerMode !== "dev") throw new Error("Invalid PI WEB Docker mode");
|
||||
return {
|
||||
kind,
|
||||
...optionalField("path", optionalString(record, "path")),
|
||||
...optionalField("source", optionalString(record, "source")),
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
...optionalField("npmRoot", optionalString(record, "npmRoot")),
|
||||
...(dockerMode === undefined ? {} : { dockerMode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -762,8 +835,9 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
|
||||
}
|
||||
|
||||
function parsePiWebCapabilities(value: unknown): PiWebCapability[] {
|
||||
if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities");
|
||||
return value;
|
||||
const capabilities = parseKnownPiWebCapabilities(value);
|
||||
if (capabilities === undefined) throw new Error("Invalid PI WEB capabilities");
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
|
||||
|
||||
@@ -10,7 +10,7 @@ function FakeWebSocket(url: string): void {
|
||||
beforeEach(() => {
|
||||
webSocketUrls.length = 0;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
import { resolveAppWebSocketUrl } from "../appUrl";
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -6,27 +7,22 @@ export function sessionEvents(session: SessionLookup, machineId = "local"): WebS
|
||||
const cwd = typeof session === "string" ? undefined : session.cwd;
|
||||
const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`;
|
||||
const sessionId = typeof session === "string" ? session : session.id;
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`));
|
||||
}
|
||||
|
||||
export function globalSessionEvents(machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/events`));
|
||||
}
|
||||
|
||||
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket {
|
||||
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
||||
const sizeQuery = initialSize === undefined ? "" : `?${new URLSearchParams({ cols: String(initialSize.cols), rows: String(initialSize.rows) }).toString()}`;
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`));
|
||||
}
|
||||
|
||||
export function realtimeEvents(machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/events`));
|
||||
}
|
||||
|
||||
function machinePrefix(machineId: string): string {
|
||||
return `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -10,36 +11,36 @@ function sessionCwd(session: SessionLookup): string | undefined {
|
||||
return typeof session === "string" ? undefined : session.cwd;
|
||||
}
|
||||
|
||||
export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
export function machineGitDiffPath(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.path !== undefined) params.set("path", options.path);
|
||||
if (options?.staged === true) params.set("staged", "true");
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
export function messagePath(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
const params = new URLSearchParams();
|
||||
const cwd = sessionCwd(session);
|
||||
if (cwd !== undefined && cwd !== "") params.set("cwd", cwd);
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
|
||||
}
|
||||
|
||||
export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.createDirs === false) params.set("createDirs", "false");
|
||||
if (options?.overwrite === false) params.set("overwrite", "false");
|
||||
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`;
|
||||
const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", path);
|
||||
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
||||
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
||||
const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
effectiveWorkspaceUploadFolder,
|
||||
uploadWorkspaceFile,
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
type WorkspaceUploadXhr,
|
||||
} from "./workspaceUploads";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("workspace upload helpers", () => {
|
||||
it("resolves effective upload defaults and workspace-relative paths", () => {
|
||||
expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads");
|
||||
@@ -40,7 +48,7 @@ describe("workspace upload helpers", () => {
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
|
||||
expect(xhr.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
|
||||
expect(xhr.headers.get("content-type")).toBe("text/plain");
|
||||
expect(xhr.body).toBe(file);
|
||||
|
||||
@@ -78,13 +86,13 @@ describe("workspace upload helpers", () => {
|
||||
});
|
||||
|
||||
const first = xhrs.at(0);
|
||||
expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
|
||||
expect(first.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
|
||||
first.emitUploadProgress(1, 2);
|
||||
first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
await Promise.resolve();
|
||||
|
||||
const second = xhrs.at(1);
|
||||
expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
|
||||
expect(second.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
|
||||
second.emitUploadProgress(3, 3);
|
||||
second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true });
|
||||
|
||||
@@ -100,6 +108,43 @@ describe("workspace upload helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards createDirs through batch upload requests", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const file = new File(["hello"], "nested.txt", { type: "text/plain" });
|
||||
|
||||
const task = uploadWorkspaceFiles("p1", "w1", [file], {
|
||||
destinationFolder: "uploads",
|
||||
createDirs: false,
|
||||
xhrFactory: xhrs.factory,
|
||||
});
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.url).toBe("https://pi.example.test/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
|
||||
xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
|
||||
await expect(task.promise).resolves.toEqual([
|
||||
{ path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels an in-flight batch upload without starting remaining files", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const files = [new File(["ab"], "a.txt"), new File(["cde"], "b.txt")];
|
||||
|
||||
const task = uploadWorkspaceFiles("p1", "w1", files, {
|
||||
destinationFolder: "uploads",
|
||||
xhrFactory: xhrs.factory,
|
||||
});
|
||||
const first = xhrs.only();
|
||||
const cancellation = expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError);
|
||||
|
||||
task.cancel();
|
||||
|
||||
await cancellation;
|
||||
expect(first.aborted).toBe(true);
|
||||
expect(xhrs.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("continues batch uploads after per-file failures and reports the failed file only", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const progress: WorkspaceUploadBatchProgress[] = [];
|
||||
@@ -146,6 +191,10 @@ class FakeXhrQueue {
|
||||
at(index: number): FakeXMLHttpRequest {
|
||||
return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.instances.length;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeXMLHttpRequest implements WorkspaceUploadXhr {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrowserResumeController } from "./browserResumeController";
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
|
||||
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred };
|
||||
}
|
||||
|
||||
function frameHarness() {
|
||||
const frames: { callback: () => void; canceled: boolean }[] = [];
|
||||
return {
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
const frame = { callback, canceled: false };
|
||||
frames.push(frame);
|
||||
return { cancel: () => { frame.canceled = true; } };
|
||||
},
|
||||
pendingCount: () => frames.filter((frame) => !frame.canceled).length,
|
||||
runNext: () => {
|
||||
const frame = frames.shift();
|
||||
if (frame === undefined) throw new Error("No scheduled frame");
|
||||
if (!frame.canceled) frame.callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowserResumeController", () => {
|
||||
it("batches overlapping focus and visible signals into one app refresh", async () => {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
const frames = frameHarness();
|
||||
const refreshGate = deferred<undefined>();
|
||||
const refreshStarted = deferred<undefined>();
|
||||
const refreshCompleted = deferred<undefined>();
|
||||
const onResumeSignal = vi.fn();
|
||||
let visible = true;
|
||||
let refreshCalls = 0;
|
||||
const controller = new BrowserResumeController({
|
||||
onResumeSignal,
|
||||
refreshAfterResume: async () => {
|
||||
refreshCalls += 1;
|
||||
refreshStarted.resolve(undefined);
|
||||
await refreshGate.promise;
|
||||
refreshCompleted.resolve(undefined);
|
||||
},
|
||||
onRefreshError: (error) => { throw error; },
|
||||
}, {
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
isDocumentVisible: () => visible,
|
||||
scheduleFrame: frames.scheduleFrame,
|
||||
});
|
||||
controller.connect();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(3);
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
expect(refreshCalls).toBe(0);
|
||||
|
||||
frames.runNext();
|
||||
await refreshStarted.promise;
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
visible = false;
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(3);
|
||||
expect(frames.pendingCount()).toBe(0);
|
||||
|
||||
refreshGate.resolve(undefined);
|
||||
await refreshCompleted.promise;
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
controller.disconnect();
|
||||
frames.runNext();
|
||||
await Promise.resolve();
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(4);
|
||||
expect(refreshCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("runs one trailing refresh when another resume arrives during active work", async () => {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
const frames = frameHarness();
|
||||
const firstGate = deferred<undefined>();
|
||||
const secondGate = deferred<undefined>();
|
||||
const firstStarted = deferred<undefined>();
|
||||
const secondStarted = deferred<undefined>();
|
||||
const secondCompleted = deferred<undefined>();
|
||||
let refreshCalls = 0;
|
||||
const controller = new BrowserResumeController({
|
||||
onResumeSignal: () => undefined,
|
||||
refreshAfterResume: async () => {
|
||||
refreshCalls += 1;
|
||||
if (refreshCalls === 1) {
|
||||
firstStarted.resolve(undefined);
|
||||
await firstGate.promise;
|
||||
return;
|
||||
}
|
||||
secondStarted.resolve(undefined);
|
||||
await secondGate.promise;
|
||||
secondCompleted.resolve(undefined);
|
||||
},
|
||||
onRefreshError: (error) => { throw error; },
|
||||
}, {
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
isDocumentVisible: () => true,
|
||||
scheduleFrame: frames.scheduleFrame,
|
||||
});
|
||||
controller.connect();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
frames.runNext();
|
||||
await firstStarted.promise;
|
||||
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
frames.runNext();
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
firstGate.resolve(undefined);
|
||||
await secondStarted.promise;
|
||||
expect(refreshCalls).toBe(2);
|
||||
|
||||
secondGate.resolve(undefined);
|
||||
await secondCompleted.promise;
|
||||
controller.disconnect();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TrailingRefreshCoordinator } from "../controllers/trailingRefreshCoordinator";
|
||||
|
||||
interface BrowserEventTarget {
|
||||
addEventListener(type: string, listener: EventListener): void;
|
||||
removeEventListener(type: string, listener: EventListener): void;
|
||||
}
|
||||
|
||||
interface ScheduledFrame {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface BrowserResumeCallbacks {
|
||||
onResumeSignal(): void;
|
||||
refreshAfterResume(): void | Promise<void>;
|
||||
onRefreshError(error: unknown): void;
|
||||
}
|
||||
|
||||
export interface BrowserResumeControllerOptions {
|
||||
windowTarget?: BrowserEventTarget | undefined;
|
||||
documentTarget?: BrowserEventTarget | undefined;
|
||||
isDocumentVisible?: (() => boolean) | undefined;
|
||||
scheduleFrame?: ((callback: () => void) => ScheduledFrame) | undefined;
|
||||
}
|
||||
|
||||
/** Owns browser resume listeners and batches focus/visibility refreshes per frame. */
|
||||
export class BrowserResumeController {
|
||||
private readonly windowTarget: BrowserEventTarget | undefined;
|
||||
private readonly documentTarget: BrowserEventTarget | undefined;
|
||||
private readonly isDocumentVisible: () => boolean;
|
||||
private readonly scheduleFrame: (callback: () => void) => ScheduledFrame;
|
||||
private readonly refreshes = new TrailingRefreshCoordinator<"browser-resume">();
|
||||
private scheduledRefresh: ScheduledFrame | undefined;
|
||||
private connected = false;
|
||||
|
||||
constructor(private readonly callbacks: BrowserResumeCallbacks, options: BrowserResumeControllerOptions = {}) {
|
||||
this.windowTarget = options.windowTarget ?? browserWindowTarget();
|
||||
this.documentTarget = options.documentTarget ?? browserDocumentTarget();
|
||||
this.isDocumentVisible = options.isDocumentVisible ?? documentIsVisible;
|
||||
this.scheduleFrame = options.scheduleFrame ?? scheduleBrowserFrame;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.connected) return;
|
||||
this.connected = true;
|
||||
this.windowTarget?.addEventListener("focus", this.onFocus);
|
||||
this.documentTarget?.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
this.windowTarget?.removeEventListener("focus", this.onFocus);
|
||||
this.documentTarget?.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.scheduledRefresh?.cancel();
|
||||
this.scheduledRefresh = undefined;
|
||||
}
|
||||
|
||||
private readonly onFocus: EventListener = () => {
|
||||
this.handleResumeSignal();
|
||||
};
|
||||
|
||||
private readonly onVisibilityChange: EventListener = () => {
|
||||
if (this.isDocumentVisible()) this.handleResumeSignal();
|
||||
};
|
||||
|
||||
private handleResumeSignal(): void {
|
||||
this.callbacks.onResumeSignal();
|
||||
if (this.scheduledRefresh !== undefined) return;
|
||||
this.scheduledRefresh = this.scheduleFrame(() => {
|
||||
this.scheduledRefresh = undefined;
|
||||
if (!this.connected) return;
|
||||
void this.refreshes.request("browser-resume", async () => {
|
||||
if (this.connected) await this.callbacks.refreshAfterResume();
|
||||
}).catch((error: unknown) => { this.callbacks.onRefreshError(error); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function browserWindowTarget(): BrowserEventTarget | undefined {
|
||||
return typeof window === "undefined" ? undefined : window;
|
||||
}
|
||||
|
||||
function browserDocumentTarget(): BrowserEventTarget | undefined {
|
||||
return typeof document === "undefined" ? undefined : document;
|
||||
}
|
||||
|
||||
function documentIsVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
function scheduleBrowserFrame(callback: () => void): ScheduledFrame {
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
const frame = window.requestAnimationFrame(() => { callback(); });
|
||||
return { cancel: () => { window.cancelAnimationFrame(frame); } };
|
||||
}
|
||||
const timer = globalThis.setTimeout(callback, 0);
|
||||
return { cancel: () => { globalThis.clearTimeout(timer); } };
|
||||
}
|
||||
@@ -80,15 +80,27 @@ describe("ViewportPositionRepairer", () => {
|
||||
const timer = firstMapEntry(scheduler.timers);
|
||||
expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS);
|
||||
|
||||
scheduler.documentElement.scrollTop = 56;
|
||||
scheduler.body.scrollTop = 78;
|
||||
scheduler.runAnimationFrame(firstFrame);
|
||||
expect(scheduler.scrollCalls).toHaveLength(2);
|
||||
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0]]);
|
||||
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||
expect(scheduler.body.scrollTop).toBe(0);
|
||||
const secondFrame = firstMapKey(scheduler.animationFrames);
|
||||
|
||||
scheduler.documentElement.scrollTop = 90;
|
||||
scheduler.body.scrollTop = 123;
|
||||
scheduler.runAnimationFrame(secondFrame);
|
||||
expect(scheduler.scrollCalls).toHaveLength(3);
|
||||
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0]]);
|
||||
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||
expect(scheduler.body.scrollTop).toBe(0);
|
||||
|
||||
scheduler.documentElement.scrollTop = 34;
|
||||
scheduler.body.scrollTop = 12;
|
||||
scheduler.runTimer(timer[0]);
|
||||
expect(scheduler.scrollCalls).toHaveLength(4);
|
||||
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0], [0, 0]]);
|
||||
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||
expect(scheduler.body.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces pending scheduled repairs", () => {
|
||||
@@ -102,6 +114,10 @@ describe("ViewportPositionRepairer", () => {
|
||||
|
||||
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
||||
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
||||
expect(scheduler.animationFrames.has(firstFrame)).toBe(false);
|
||||
expect(scheduler.timers.has(firstTimer)).toBe(false);
|
||||
expect(scheduler.animationFrames.size).toBe(1);
|
||||
expect(scheduler.timers.size).toBe(1);
|
||||
});
|
||||
|
||||
it("clears pending work when repair is no longer needed", () => {
|
||||
@@ -115,5 +131,7 @@ describe("ViewportPositionRepairer", () => {
|
||||
|
||||
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
||||
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
||||
expect(scheduler.animationFrames.size).toBe(0);
|
||||
expect(scheduler.timers.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
|
||||
@@ -20,6 +20,10 @@ export interface AppState {
|
||||
isReceivingPartialStream: boolean;
|
||||
/** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */
|
||||
sendingPrompts: Record<string, true>;
|
||||
/** Client-side queued sends waiting for a just-created backend session, keyed by sessionId. */
|
||||
clientQueuedSessionMessages: Record<string, QueuedSessionMessage[]>;
|
||||
/** Client-initiated session creation requests waiting for the server. */
|
||||
startingSessionCount: number;
|
||||
isLoadingProjects: boolean;
|
||||
isLoadingWorkspaces: boolean;
|
||||
selectedProject: Project | undefined;
|
||||
@@ -72,6 +76,8 @@ export type AuthDialogState =
|
||||
|
||||
export type WorkspaceScopedStateReset = Pick<AppState,
|
||||
| "sessions"
|
||||
| "clientQueuedSessionMessages"
|
||||
| "startingSessionCount"
|
||||
| "fileTree"
|
||||
| "expandedDirs"
|
||||
| "selectedFilePath"
|
||||
@@ -89,6 +95,8 @@ export type WorkspaceScopedStateReset = Pick<AppState,
|
||||
export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
|
||||
return {
|
||||
sessions: [],
|
||||
clientQueuedSessionMessages: {},
|
||||
startingSessionCount: 0,
|
||||
fileTree: [],
|
||||
expandedDirs: {},
|
||||
selectedFilePath: undefined,
|
||||
@@ -121,6 +129,8 @@ export function initialAppState(): AppState {
|
||||
isLoadingEarlierMessages: false,
|
||||
isReceivingPartialStream: false,
|
||||
sendingPrompts: {},
|
||||
clientQueuedSessionMessages: {},
|
||||
startingSessionCount: 0,
|
||||
isLoadingProjects: false,
|
||||
isLoadingWorkspaces: false,
|
||||
selectedProject: undefined,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAppUrl, resolveAppWebSocketUrl, type AppUrlContext } from "./appUrl";
|
||||
|
||||
const rootHttpContext: AppUrlContext = {
|
||||
viteBaseUrl: "/",
|
||||
documentBaseUrl: "http://pi.example.test/",
|
||||
};
|
||||
|
||||
const nestedHttpsContext: AppUrlContext = {
|
||||
viteBaseUrl: "./",
|
||||
documentBaseUrl: "https://pi.example.test/test/ai/",
|
||||
};
|
||||
|
||||
describe("application URLs", () => {
|
||||
it("resolves app-owned paths at an HTTP root deployment", () => {
|
||||
expect(resolveAppUrl("api/pi-web/status", rootHttpContext)).toBe("http://pi.example.test/api/pi-web/status");
|
||||
expect(resolveAppUrl("/pi-web-plugins/manifest.json", rootHttpContext)).toBe("http://pi.example.test/pi-web-plugins/manifest.json");
|
||||
});
|
||||
|
||||
it("resolves paths within a canonical nested HTTPS deployment", () => {
|
||||
expect(resolveAppUrl("api/pi-web/status", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/api/pi-web/status");
|
||||
expect(resolveAppUrl("/pi-web-plugins/manifest.json", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/pi-web-plugins/manifest.json");
|
||||
});
|
||||
|
||||
it("preserves encoded path segments and query parameters", () => {
|
||||
expect(resolveAppUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10", nestedHttpsContext))
|
||||
.toBe("https://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application WebSocket URLs", () => {
|
||||
it("maps root HTTP URLs to absolute ws URLs", () => {
|
||||
expect(resolveAppWebSocketUrl("api/machines/local/events", rootHttpContext)).toBe("ws://pi.example.test/api/machines/local/events");
|
||||
});
|
||||
|
||||
it("maps nested HTTPS URLs to absolute wss URLs without losing path or query data", () => {
|
||||
expect(resolveAppWebSocketUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one", nestedHttpsContext))
|
||||
.toBe("wss://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface AppUrlContext {
|
||||
viteBaseUrl: string;
|
||||
documentBaseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PI WEB-owned reference at a browser boundary.
|
||||
*
|
||||
* Core callers keep paths application-relative (no leading slash), encode every dynamic path segment,
|
||||
* and resolve exactly once. Leading slashes are accepted only for existing plugin-manifest compatibility
|
||||
* and mean the application root rather than the origin root.
|
||||
*/
|
||||
export function resolveAppUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string {
|
||||
const applicationBaseUrl = new URL(context.viteBaseUrl, context.documentBaseUrl);
|
||||
return new URL(appRelativePath(path), applicationBaseUrl).toString();
|
||||
}
|
||||
|
||||
export function resolveAppWebSocketUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string {
|
||||
const url = new URL(resolveAppUrl(path, context));
|
||||
if (url.protocol === "http:") {
|
||||
url.protocol = "ws:";
|
||||
} else if (url.protocol === "https:") {
|
||||
url.protocol = "wss:";
|
||||
} else {
|
||||
throw new Error(`Cannot create a WebSocket URL from ${url.protocol}`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function browserAppUrlContext(): AppUrlContext {
|
||||
return {
|
||||
viteBaseUrl: import.meta.env.BASE_URL,
|
||||
documentBaseUrl: document.baseURI,
|
||||
};
|
||||
}
|
||||
|
||||
function appRelativePath(path: string): string {
|
||||
// A leading slash means the application root, not the origin root, so it must stay within nested deployments.
|
||||
return path.startsWith("/") ? `.${path}` : path;
|
||||
}
|
||||
@@ -57,9 +57,12 @@ describe("cached new sessions", () => {
|
||||
rememberCachedNewSession(baseSession, "local", storage);
|
||||
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage);
|
||||
|
||||
expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(mergeCachedNewSessions("/repo", [baseSession], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false);
|
||||
const cachedOnly = mergeCachedNewSessions("/repo", [], "local", storage);
|
||||
const mergedWithServerSession = mergeCachedNewSessions("/repo", [baseSession], "local", storage);
|
||||
|
||||
expect(cachedOnly.map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(mergedWithServerSession.map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(isCachedNewSessionInfo(mergedWithServerSession[0])).toBe(false);
|
||||
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
cwd: session.cwd,
|
||||
...(session.persisted === undefined ? {} : { persisted: session.persisted }),
|
||||
...(session.name === undefined ? {} : { name: session.name }),
|
||||
created: session.created,
|
||||
modified: session.modified,
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("ChatScrollController", () => {
|
||||
expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" });
|
||||
});
|
||||
|
||||
it("captures the session id when scheduling a delayed save", () => {
|
||||
it("cancels the previous delayed save and passes the latest session id", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler);
|
||||
const saved: string[] = [];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { writeClipboardText } from "./clipboard";
|
||||
|
||||
describe("writeClipboardText", () => {
|
||||
it("uses the synchronous fallback directly in insecure contexts", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: false, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("uses the async Clipboard API in secure contexts", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
expect(fallbackWriteText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when the async Clipboard API is unavailable", async () => {
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back when the async Clipboard API rejects", async () => {
|
||||
const writeText = vi.fn(() => Promise.reject(new Error("denied")));
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface ClipboardTextWriteHost {
|
||||
readonly isSecureContext: boolean;
|
||||
readonly writeText?: (text: string) => Promise<void>;
|
||||
readonly fallbackWriteText: (text: string) => boolean;
|
||||
}
|
||||
|
||||
export async function writeClipboardText(text: string, host: ClipboardTextWriteHost = browserClipboardTextWriteHost()): Promise<boolean> {
|
||||
if (!host.isSecureContext) return host.fallbackWriteText(text);
|
||||
|
||||
if (host.writeText !== undefined) {
|
||||
try {
|
||||
await host.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return host.fallbackWriteText(text);
|
||||
}
|
||||
}
|
||||
|
||||
return host.fallbackWriteText(text);
|
||||
}
|
||||
|
||||
function browserClipboardTextWriteHost(): ClipboardTextWriteHost {
|
||||
const fallbackWriteText = (text: string) => writeClipboardTextWithSelectionFallback(text);
|
||||
const writeText = browserClipboardWriteText();
|
||||
return writeText === undefined
|
||||
? { isSecureContext: browserIsSecureContext(), fallbackWriteText }
|
||||
: { isSecureContext: browserIsSecureContext(), writeText, fallbackWriteText };
|
||||
}
|
||||
|
||||
function browserIsSecureContext(): boolean {
|
||||
return typeof window !== "undefined" && window.isSecureContext;
|
||||
}
|
||||
|
||||
function browserClipboardWriteText(): ((text: string) => Promise<void>) | undefined {
|
||||
if (typeof navigator === "undefined" || !("clipboard" in navigator)) return undefined;
|
||||
return navigator.clipboard.writeText.bind(navigator.clipboard);
|
||||
}
|
||||
|
||||
function writeClipboardTextWithSelectionFallback(text: string): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const selection = document.getSelection();
|
||||
const selectedRanges = selection === null ? [] : selectionRanges(selection);
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.readOnly = true;
|
||||
textarea.setAttribute("aria-hidden", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.padding = "0";
|
||||
textarea.style.border = "0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
document.body.append(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Required for HTTP/private-network pages where navigator.clipboard is unavailable.
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textarea.remove();
|
||||
restoreSelection(selection, selectedRanges);
|
||||
restoreFocus(activeElement);
|
||||
}
|
||||
}
|
||||
|
||||
function selectionRanges(selection: Selection): Range[] {
|
||||
const ranges: Range[] = [];
|
||||
for (let index = 0; index < selection.rangeCount; index += 1) {
|
||||
ranges.push(selection.getRangeAt(index));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function restoreSelection(selection: Selection | null, ranges: readonly Range[]): void {
|
||||
if (selection === null) return;
|
||||
try {
|
||||
selection.removeAllRanges();
|
||||
for (const range of ranges) selection.addRange(range);
|
||||
} catch {
|
||||
// Restoring the prior selection is best-effort; the copy result should remain authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFocus(element: Element | null): void {
|
||||
if (typeof HTMLElement === "undefined" || !(element instanceof HTMLElement)) return;
|
||||
try {
|
||||
element.focus({ preventScroll: true });
|
||||
} catch {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatLine } from "./shared";
|
||||
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
|
||||
|
||||
describe("chatQueuedMessageSections", () => {
|
||||
it("labels client-side pending-start sends separately from server queued messages", () => {
|
||||
const sections = chatQueuedMessageSections(
|
||||
[{ kind: "followUp", text: "queued before start" }],
|
||||
[{ kind: "steer", text: "server queued" }],
|
||||
);
|
||||
|
||||
expect(sections).toEqual([
|
||||
{
|
||||
heading: "Queued until session starts",
|
||||
detail: "Will send once the backend session is ready",
|
||||
messages: [{ kind: "followUp", text: "queued before start" }],
|
||||
},
|
||||
{
|
||||
heading: "Queued messages",
|
||||
detail: "1 pending · Stop clears the queue",
|
||||
messages: [{ kind: "steer", text: "server queued" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatMessageMetadataLabel", () => {
|
||||
it("uses one full date and model label without a model prefix", () => {
|
||||
const timestamp = "2026-07-10T19:15:30.000Z";
|
||||
const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp));
|
||||
|
||||
expect(chatMessageMetadataLabel({
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
meta: { timestamp, model: { provider: "provider", id: "model" } },
|
||||
})).toBe(`${formattedTimestamp} · provider/model`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView technical-event groups", () => {
|
||||
const messages: ChatLine[] = [
|
||||
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
|
||||
{ role: "tool", parts: [{ type: "toolExecution", toolName: "read", summary: "inspect a file", status: "success", resultText: "large result" }] },
|
||||
];
|
||||
|
||||
it("defers a closed body while retaining native disclosure and group scroll anchors", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
|
||||
const closed = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([]);
|
||||
expect(templateStaticMarkup(closed)).toContain("<details");
|
||||
expect(templateStaticMarkup(closed)).toContain("<summary>");
|
||||
expect(templateStaticMarkup(closed)).toContain('aria-hidden="true"');
|
||||
expect(templateValuesAfterMarker(closed, "?open=")).toEqual([false]);
|
||||
expect(templateValuesAfterMarker(closed, "data-scroll-anchor-id=")).toEqual(["g:40"]);
|
||||
expect(templateValuesAfterMarker(closed, "data-marker-id=")).toEqual(["g:41"]);
|
||||
});
|
||||
|
||||
// Direct handler extraction keeps this node-environment test focused on the
|
||||
// native details toggle wiring without introducing a component-wide DOM shim.
|
||||
it("renders an opened body with event anchors and removes it when closed again", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
const initiallyClosed = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
dispatchDetailsToggle(templateEventHandler(initiallyClosed, "@toggle="), true);
|
||||
const opened = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
|
||||
expect(templateValuesAfterMarker(opened, "?open=")).toEqual([true]);
|
||||
expect(templateValuesAfterMarker(opened, "data-scroll-anchor-id=")).toEqual(["g:40", "e:40", "e:41"]);
|
||||
|
||||
bodyCalls.length = 0;
|
||||
dispatchDetailsToggle(templateEventHandler(opened, "@toggle="), false);
|
||||
const closedAgain = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([]);
|
||||
expect(templateValuesAfterMarker(closedAgain, "?open=")).toEqual([false]);
|
||||
expect(templateValuesAfterMarker(closedAgain, "data-scroll-anchor-id=")).toEqual(["g:40"]);
|
||||
});
|
||||
|
||||
it("renders a live tail body by default", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
|
||||
const live = renderMessageGroup(view, messages, 40, 41, true);
|
||||
|
||||
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
|
||||
expect(templateValuesAfterMarker(live, "?open=")).toEqual([true]);
|
||||
expect(templateValues(live)).toContain("msg event-group live");
|
||||
expect(templateValues(live)).toContain("live events");
|
||||
});
|
||||
});
|
||||
|
||||
interface GroupBodyRenderCall {
|
||||
messages: ChatLine[];
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
|
||||
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
||||
type TemplateEventHandler = (event: Event) => void;
|
||||
|
||||
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
|
||||
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
||||
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
||||
return method.call(view, messages, startIndex, endIndex, defaultOpen);
|
||||
}
|
||||
|
||||
function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
|
||||
const method: unknown = Reflect.get(view, "renderMessageGroupBody");
|
||||
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
|
||||
const calls: GroupBodyRenderCall[] = [];
|
||||
const observed: RenderMessageGroupBody = function (messages, startIndex) {
|
||||
calls.push({ messages, startIndex });
|
||||
return method.call(this, messages, startIndex);
|
||||
};
|
||||
if (!Reflect.set(view, "renderMessageGroupBody", observed)) throw new Error("Could not observe ChatView.renderMessageGroupBody");
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBody {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
|
||||
const strings = templateStrings(template);
|
||||
const values = templateValues(template);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
|
||||
}
|
||||
throw new Error(`Expected template event handler after ${marker}`);
|
||||
}
|
||||
|
||||
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
|
||||
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
|
||||
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
|
||||
class StubDetailsElement extends EventTarget {
|
||||
constructor(readonly open: boolean) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
Reflect.set(globalThis, "HTMLDetailsElement", StubDetailsElement);
|
||||
try {
|
||||
const details = new StubDetailsElement(open);
|
||||
details.addEventListener("toggle", (event) => { handler(event); });
|
||||
details.dispatchEvent(new Event("toggle"));
|
||||
} finally {
|
||||
if (hadDetailsElement) Reflect.set(globalThis, "HTMLDetailsElement", previousDetailsElement);
|
||||
else Reflect.deleteProperty(globalThis, "HTMLDetailsElement");
|
||||
}
|
||||
}
|
||||
|
||||
function templateStaticMarkup(template: TemplateResult): string {
|
||||
const chunks: string[] = [];
|
||||
visit(template);
|
||||
return chunks.join("");
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
chunks.push(...templateStrings(value));
|
||||
for (const child of templateValues(value)) visit(child);
|
||||
}
|
||||
}
|
||||
|
||||
function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] {
|
||||
const matches: unknown[] = [];
|
||||
visit(template);
|
||||
return matches;
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (strings[index]?.includes(marker) === true) matches.push(values[index]);
|
||||
visit(values[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
@@ -3,18 +3,18 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { ChatDisclosureController } from "../chatDisclosure";
|
||||
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||
import type { SessionActivity, SessionStatus } from "../api";
|
||||
import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
|
||||
import type { ChatLine, ChatPart } from "./shared";
|
||||
import { chatStyles } from "./shared";
|
||||
import "./ConversationMeter";
|
||||
import "./FormattedText";
|
||||
import "./ToolExecutionView";
|
||||
|
||||
const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||
|
||||
const partialStreamNoticeBodies = [
|
||||
"You opened this chat while the assistant was already replying. The complete answer will appear shortly.",
|
||||
@@ -38,6 +38,41 @@ function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export interface QueuedMessageSection {
|
||||
heading: string;
|
||||
detail: string;
|
||||
messages: QueuedSessionMessage[];
|
||||
}
|
||||
|
||||
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
|
||||
return [
|
||||
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
|
||||
serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued },
|
||||
].filter((section): section is QueuedMessageSection => section !== undefined);
|
||||
}
|
||||
|
||||
export function chatMessageMetadataLabel(message: ChatLine): string {
|
||||
const timestamp = message.meta?.timestamp;
|
||||
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
||||
const model = chatMessageModelLabel(message);
|
||||
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
|
||||
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
|
||||
}
|
||||
|
||||
function formatMessageTimestamp(timestamp: string): string | undefined {
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return undefined;
|
||||
return messageTimestampFormatter.format(date);
|
||||
}
|
||||
|
||||
function chatMessageModelLabel(message: ChatLine): string | undefined {
|
||||
const model = message.meta?.model;
|
||||
if (model === undefined) return undefined;
|
||||
const id = model.responseId ?? model.id;
|
||||
if (id === undefined || id === "") return model.provider;
|
||||
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
|
||||
}
|
||||
|
||||
@customElement("chat-view")
|
||||
export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) messages: ChatLine[] = [];
|
||||
@@ -51,6 +86,7 @@ export class ChatView extends LitElement {
|
||||
@property({ type: Boolean }) isSendingPrompt = false;
|
||||
@property({ type: Boolean }) isCompacting = false;
|
||||
@property({ type: Number }) pendingMessageCount = 0;
|
||||
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ attribute: false }) onLoadMore?: () => void;
|
||||
@@ -69,7 +105,7 @@ export class ChatView extends LitElement {
|
||||
private groupedMessagesInput?: ChatLine[];
|
||||
private groupedMessagesStart = 0;
|
||||
private groupedMessagesCache: ChatGroup[] = [];
|
||||
private readonly messageMetaCache = new WeakMap<ChatLine, { short: string; full: string }>();
|
||||
private readonly messageMetaCache = new WeakMap<ChatLine, string>();
|
||||
private readonly messageCopyTextCache = new WeakMap<ChatLine, string>();
|
||||
private partialStreamNoticeBody: string | undefined;
|
||||
private lastScrollTop = 0;
|
||||
@@ -220,15 +256,18 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
private renderQueuedMessages() {
|
||||
const queued = this.status?.queuedMessages ?? [];
|
||||
if (queued.length === 0) return null;
|
||||
const serverQueued = this.status?.queuedMessages ?? [];
|
||||
return html`${chatQueuedMessageSections(this.clientQueuedMessages, serverQueued).map((section) => this.renderQueuedMessageList(section))}`;
|
||||
}
|
||||
|
||||
private renderQueuedMessageList(section: QueuedMessageSection) {
|
||||
return html`
|
||||
<aside class="queued-messages" aria-live="polite">
|
||||
<div class="queued-header">
|
||||
<strong>Queued messages</strong>
|
||||
<small>${queued.length} pending · Stop clears the queue</small>
|
||||
<strong>${section.heading}</strong>
|
||||
<small>${section.detail}</small>
|
||||
</div>
|
||||
${queued.map((message, index) => html`
|
||||
${section.messages.map((message, index) => html`
|
||||
<div class="queued-message">
|
||||
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
|
||||
<formatted-text .text=${message.text}></formatted-text>
|
||||
@@ -352,21 +391,27 @@ export class ChatView extends LitElement {
|
||||
<b class="label">${defaultOpen ? "live events" : "events"}</b>
|
||||
<span>${summarizeChatGroup(messages)}</span>
|
||||
</summary>
|
||||
<div class="group-body">
|
||||
${messages.map((message, offset) => {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
return html`
|
||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${open ? this.renderMessageGroupBody(messages, startIndex) : null}
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessageGroupBody(messages: ChatLine[], startIndex: number) {
|
||||
return html`
|
||||
<div class="group-body">
|
||||
${messages.map((message, offset) => {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
return html`
|
||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScrollMarker(markerId: string) {
|
||||
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
|
||||
}
|
||||
@@ -379,7 +424,7 @@ export class ChatView extends LitElement {
|
||||
<b class="label">${message.role}</b>
|
||||
<div class="msg-header-trailing">
|
||||
${this.renderMessageActions(message, key)}
|
||||
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta.full} aria-label=${meta.full} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short}</span>
|
||||
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta} aria-label=${meta} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -421,55 +466,23 @@ export class ChatView extends LitElement {
|
||||
|
||||
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const ok = await this.writeClipboard(this.messageCopyText(message));
|
||||
if (!ok) return;
|
||||
const copied = await writeClipboardText(this.messageCopyText(message));
|
||||
if (!copied) return;
|
||||
this.copiedMessageKey = key;
|
||||
window.setTimeout(() => {
|
||||
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
private async writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
|
||||
private messageMetaLabel(message: ChatLine): string {
|
||||
const cached = this.messageMetaCache.get(message);
|
||||
if (cached !== undefined) return cached;
|
||||
const timestamp = message.meta?.timestamp;
|
||||
const model = this.modelLabel(message);
|
||||
if (timestamp === undefined && model === undefined) {
|
||||
const empty = { short: "no info", full: "No Pi message metadata available" };
|
||||
this.messageMetaCache.set(message, empty);
|
||||
return empty;
|
||||
}
|
||||
const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp);
|
||||
const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== "");
|
||||
const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== "");
|
||||
const label = { short: parts.join(" · "), full: fullParts.join(" · ") };
|
||||
const label = chatMessageMetadataLabel(message);
|
||||
this.messageMetaCache.set(message, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
private formatTimestamp(timestamp: string): { short: string; full: string } | undefined {
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return undefined;
|
||||
return { short: shortTimestampFormatter.format(date), full: fullTimestampFormatter.format(date) };
|
||||
}
|
||||
|
||||
private modelLabel(message: ChatLine): string | undefined {
|
||||
const model = message.meta?.model;
|
||||
if (model === undefined) return undefined;
|
||||
const id = model.responseId ?? model.id;
|
||||
if (id === undefined || id === "") return model.provider;
|
||||
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
|
||||
}
|
||||
|
||||
private renderPart(part: ChatPart, message?: ChatLine) {
|
||||
if (part.type === "text" && message?.role === "bash") return html`<pre class="part shell-output">${part.text}</pre>`;
|
||||
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { toSafeMarkdownHtml } from "../formatting/markdown";
|
||||
import { formattedTextStyles } from "./shared";
|
||||
|
||||
@@ -49,8 +50,8 @@ export class FormattedText extends LitElement {
|
||||
};
|
||||
|
||||
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
||||
const ok = await writeClipboard(text);
|
||||
this.setCopyButtonState(button, ok ? "copied" : "failed");
|
||||
const copied = await writeClipboardText(text);
|
||||
this.setCopyButtonState(button, copied ? "copied" : "failed");
|
||||
window.setTimeout(() => {
|
||||
this.setCopyButtonState(button, "idle");
|
||||
}, 1200);
|
||||
@@ -66,12 +67,3 @@ export class FormattedText extends LitElement {
|
||||
|
||||
static override styles = formattedTextStyles;
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export class MachineDialog extends LitElement {
|
||||
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
|
||||
.body { display: grid; gap: 8px; padding: 12px; min-height: 0; overflow: auto; }
|
||||
label { display: grid; gap: 6px; color: var(--pi-muted); }
|
||||
input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
|
||||
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
|
||||
.hint { color: var(--pi-muted); }
|
||||
.intro { margin: 4px 0 0; line-height: 1.4; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
import { MachineController } from "../controllers/machineController";
|
||||
import { ProjectController } from "../controllers/projectController";
|
||||
import { PiWebStatusController } from "../controllers/piWebStatusController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
|
||||
@@ -20,6 +21,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { selectedMachineId } from "../controllers/types";
|
||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
||||
import { RealtimeSocket } from "../sessionSocket";
|
||||
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||
@@ -29,6 +31,7 @@ import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { BrowserResumeController } from "../appShell/browserResumeController";
|
||||
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
|
||||
@@ -130,6 +133,11 @@ export class PiWebApp extends LitElement {
|
||||
() => { this.updateUrl(); },
|
||||
this.projects,
|
||||
);
|
||||
private readonly piWebStatusController = new PiWebStatusController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
{ onRefreshError: (machineId, error) => { console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } },
|
||||
);
|
||||
private readonly files = new FileExplorerController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
@@ -147,6 +155,11 @@ export class PiWebApp extends LitElement {
|
||||
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
|
||||
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly browserResume = new BrowserResumeController({
|
||||
onResumeSignal: () => { this.handleBrowserResumeSignal(); },
|
||||
refreshAfterResume: () => this.refreshAfterBrowserResume(),
|
||||
onRefreshError: (error) => { console.warn("Failed to refresh after browser resume", error); },
|
||||
});
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly panelResize = new PanelResizeController(this);
|
||||
private readonly navigationSections = new NavigationSectionsController(
|
||||
@@ -190,24 +203,6 @@ export class PiWebApp extends LitElement {
|
||||
this.appShell.repairViewportPosition();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
};
|
||||
private readonly onFocus = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
};
|
||||
private readonly onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
}
|
||||
};
|
||||
private readonly onSystemLightThemeChange = () => {
|
||||
if (this.themePreference.auto) this.applyPreferredTheme(false);
|
||||
};
|
||||
@@ -231,8 +226,7 @@ export class PiWebApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("popstate", this.onPopState);
|
||||
window.addEventListener("pageshow", this.onPageShow);
|
||||
window.addEventListener("focus", this.onFocus);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.browserResume.connect();
|
||||
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||
this.applyPreferredTheme(false);
|
||||
@@ -247,8 +241,7 @@ export class PiWebApp extends LitElement {
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("popstate", this.onPopState);
|
||||
window.removeEventListener("pageshow", this.onPageShow);
|
||||
window.removeEventListener("focus", this.onFocus);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.browserResume.disconnect();
|
||||
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
|
||||
this.keyboard.reset();
|
||||
@@ -293,11 +286,25 @@ export class PiWebApp extends LitElement {
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
|
||||
private handleBrowserResumeSignal(): void {
|
||||
this.appShell.repairViewportPosition();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
}
|
||||
|
||||
private async refreshAfterBrowserResume(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshMachineActivities(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
]);
|
||||
}
|
||||
|
||||
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
this.piWebStatusDeferredTimer = window.setTimeout(() => {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
void this.refreshPiWebStatus();
|
||||
void this.piWebStatusController.refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
@@ -307,17 +314,6 @@ export class PiWebApp extends LitElement {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
}
|
||||
|
||||
private async refreshPiWebStatus(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
const piWebStatus = await piWebApi.piWebStatus(machineId);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
|
||||
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise<void> {
|
||||
try {
|
||||
await this.activity.refresh(machineId);
|
||||
@@ -1016,7 +1012,10 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private canDeleteArchivedSessions(): boolean {
|
||||
const runtime = this.selectedMachineRuntime();
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||
// COMPAT-CAP sessions.deleteArchived: older federated machines may support
|
||||
// the legacy DELETE route without advertising runtime capabilities. Only
|
||||
// block when capability discovery succeeds and reports no support.
|
||||
return runtime?.ok !== true || supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||
}
|
||||
|
||||
private canReloadSessions(): boolean {
|
||||
@@ -1029,6 +1028,10 @@ export class PiWebApp extends LitElement {
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
|
||||
}
|
||||
|
||||
private hasAuthoritativeSessionPersistence(): boolean {
|
||||
return runtimeHasAuthoritativeSessionPersistence(this.selectedMachineRuntime());
|
||||
}
|
||||
|
||||
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
|
||||
if (machineId === "local") return true;
|
||||
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
|
||||
@@ -1119,10 +1122,12 @@ export class PiWebApp extends LitElement {
|
||||
.sessionActivities=${this.state.sessionActivities}
|
||||
.sendingPrompts=${this.state.sendingPrompts}
|
||||
.selectedSession=${this.state.selectedSession}
|
||||
.startingSessionCount=${this.state.startingSessionCount}
|
||||
.canStartSession=${!!this.state.selectedWorkspace}
|
||||
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
||||
.canReloadSessions=${this.canReloadSessions()}
|
||||
.canCleanupSessions=${this.canCleanupSessions()}
|
||||
.authoritativeSessionPersistence=${this.hasAuthoritativeSessionPersistence()}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
|
||||
.collapsible=${true}
|
||||
@@ -1141,7 +1146,7 @@ export class PiWebApp extends LitElement {
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
|
||||
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||
.onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())}
|
||||
.onStartSession=${() => this.startSessionFromNavigation()}
|
||||
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
|
||||
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||
@@ -1176,6 +1181,24 @@ export class PiWebApp extends LitElement {
|
||||
await this.focusNavigationTarget(nextTarget);
|
||||
}
|
||||
|
||||
private async startSessionFromNavigation(): Promise<void> {
|
||||
const seq = ++this.navigationSelectionSeq;
|
||||
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
|
||||
|
||||
this.navigationSections.advanceAfterSelection("sessions");
|
||||
await this.startSessionAndOpenChat(isCurrentSelection);
|
||||
}
|
||||
|
||||
private async startSessionAndOpenChat(shouldComplete: () => boolean = () => true): Promise<void> {
|
||||
// `startSession()` remains in flight until the backend session resolves;
|
||||
// open the chat as soon as the controller has inserted the temporary row.
|
||||
const start = this.sessions.startSession().catch((error: unknown) => {
|
||||
if (shouldComplete()) this.setState({ error: String(error) });
|
||||
});
|
||||
if (shouldComplete()) await this.focusChatComposer();
|
||||
void start;
|
||||
}
|
||||
|
||||
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
|
||||
if (target === "chat") {
|
||||
await this.focusChatComposer();
|
||||
@@ -1465,7 +1488,7 @@ export class PiWebApp extends LitElement {
|
||||
const existing = this.machinePluginLoadPromises.get(machine.id);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||
machineId: machine.id,
|
||||
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
|
||||
}))
|
||||
@@ -1545,9 +1568,10 @@ export class PiWebApp extends LitElement {
|
||||
refreshFiles: () => this.files.refreshFiles(),
|
||||
refreshGit: () => this.git.refreshGit(),
|
||||
refreshAppData: () => this.refreshAppData(),
|
||||
checkForPiWebUpdates: () => this.piWebStatusController.checkForUpdates(),
|
||||
reloadPage: () => { this.hardReloadApp(); },
|
||||
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
||||
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
||||
startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()),
|
||||
archiveSession: () => this.sessions.archiveSession(),
|
||||
reloadSession: () => this.sessions.reloadSession(),
|
||||
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
|
||||
@@ -1830,6 +1854,25 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessions.send(text, streamingBehavior, attachments, delivery);
|
||||
}
|
||||
|
||||
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
|
||||
// be a fresh reference on every render, forcing Lit to re-commit the bindings
|
||||
// each time the app re-renders; bound class fields keep them constant.
|
||||
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
|
||||
this.sendPrompt(text, streamingBehavior, attachments, delivery);
|
||||
};
|
||||
|
||||
private readonly handleStopActiveWork = (): void => {
|
||||
void this.sessions.stopActiveWork();
|
||||
};
|
||||
|
||||
private readonly handleSelectModel = (): void => {
|
||||
void this.openModelDialog();
|
||||
};
|
||||
|
||||
private readonly handleSelectThinking = (): void => {
|
||||
void this.openThinkingDialog();
|
||||
};
|
||||
|
||||
private renderContextBar() {
|
||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||
return html`
|
||||
@@ -1888,8 +1931,8 @@ export class PiWebApp extends LitElement {
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[state.selectedSession.id] ?? []} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
|
||||
<status-bar .status=${state.status}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
@@ -1904,7 +1947,7 @@ export class PiWebApp extends LitElement {
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class ProjectDialog extends LitElement {
|
||||
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
|
||||
.body { display: grid; gap: 12px; padding: 12px; min-height: 0; }
|
||||
label { display: grid; gap: 6px; color: var(--pi-muted); }
|
||||
input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
|
||||
.check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: var(--pi-text); }
|
||||
.suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); }
|
||||
.suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid var(--pi-border); border-radius: 0; background: transparent; color: var(--pi-text); padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
|
||||
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
|
||||
import { inputModeForDraft } from "../inputModes";
|
||||
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
@@ -42,7 +42,14 @@ export class PromptEditor extends LitElement {
|
||||
@property({ attribute: false }) availableThinkingLevels: readonly string[] = [];
|
||||
@query(".markdown-editor") private editorHost?: HTMLDivElement;
|
||||
@query(".attachment-input") private attachmentInput?: HTMLInputElement;
|
||||
@state() private draft = "";
|
||||
// `draft` is the live document text but is intentionally NOT reactive: it
|
||||
// changes on every keystroke and the visible text is owned by CodeMirror, not
|
||||
// by Lit's render. Re-rendering the surrounding template on each keystroke is
|
||||
// wasted work and, on iOS, can interrupt an in-progress touch gesture (the
|
||||
// long-press edit/paste callout). Only `currentInputMode` (shell vs. normal)
|
||||
// is reactive, since that is the only draft-derived value the template shows.
|
||||
private draft = "";
|
||||
@state() private currentInputMode: InputMode = { kind: "normal" };
|
||||
@state() private completions: CompletionItem[] = [];
|
||||
@state() private selectedIndex = 0;
|
||||
@state() private attachments: PendingAttachment[] = [];
|
||||
@@ -64,17 +71,29 @@ export class PromptEditor extends LitElement {
|
||||
if (previousKey !== undefined) saveDraft(previousKey, this.draft);
|
||||
const currentKey = draftStorageKey(this.machineId, this.sessionId);
|
||||
this.draft = currentKey !== undefined ? loadDraft(currentKey) : "";
|
||||
this.currentInputMode = inputModeForDraft(this.draft);
|
||||
this.completions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
protected override shouldUpdate(changed: PropertyValues<this>): boolean {
|
||||
// Status updates churn once per token during streaming and hand us a fresh
|
||||
// object reference each time. When nothing else changed, only re-render if a
|
||||
// status field the template actually displays differs, so streaming does not
|
||||
// disturb the editor DOM (and any in-progress touch gesture survives).
|
||||
if (changed.has("status") && changed.size === 1) {
|
||||
return !sessionStatusRenderEqual(changed.get("status"), this.status);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.createEditor();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues) {
|
||||
if (changed.has("disabled")) this.updateEditorDisabledState();
|
||||
if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
|
||||
if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -84,8 +103,8 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
|
||||
override render() {
|
||||
const inputMode = inputModeForDraft(this.draft);
|
||||
const shellMode = inputMode.kind === "shell";
|
||||
const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined;
|
||||
const shellMode = shellInputMode !== undefined;
|
||||
const queuesInput = this.canSteer || this.isCompacting;
|
||||
const busy = this.disabled || this.sending;
|
||||
return html`
|
||||
@@ -94,7 +113,7 @@ export class PromptEditor extends LitElement {
|
||||
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
|
||||
<input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
|
||||
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
|
||||
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
|
||||
${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
|
||||
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
|
||||
${this.renderAttachments()}
|
||||
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
|
||||
@@ -288,6 +307,8 @@ export class PromptEditor extends LitElement {
|
||||
this.draft = value;
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) saveDraft(key, this.draft);
|
||||
const nextInputMode = inputModeForDraft(this.draft);
|
||||
if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode;
|
||||
void this.refreshCompletions();
|
||||
}
|
||||
|
||||
@@ -432,16 +453,33 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private resetComposer() {
|
||||
this.draft = "";
|
||||
this.currentInputMode = { kind: "normal" };
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) clearDraft(key);
|
||||
this.completions = [];
|
||||
this.attachments = [];
|
||||
this.attachmentError = undefined;
|
||||
// `draft` is not reactive, so the cleared text will not flow to CodeMirror
|
||||
// via `updated()`; push it to the editor document explicitly.
|
||||
this.syncEditorDoc();
|
||||
}
|
||||
|
||||
static override styles = promptEditorStyles;
|
||||
}
|
||||
|
||||
// The only `status` fields the template reads directly are the model identity
|
||||
// and thinking level (shown in renderCompactStatus). Everything else the editor
|
||||
// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate
|
||||
// property that Lit already diffs by value. Comparing just these fields lets us
|
||||
// ignore the per-token status churn that does not change anything on screen.
|
||||
function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === undefined || b === undefined) return false;
|
||||
return a.model?.id === b.model?.id
|
||||
&& a.model?.provider === b.model?.provider
|
||||
&& a.thinkingLevel === b.thinkingLevel;
|
||||
}
|
||||
|
||||
function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined {
|
||||
if (typeof machineId !== "string" || machineId === "") return undefined;
|
||||
if (typeof sessionId !== "string" || sessionId === "") return undefined;
|
||||
|
||||
@@ -233,7 +233,7 @@ export class SessionCleanupDialog extends LitElement {
|
||||
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 10px; }
|
||||
.toggle-row { display: grid; grid-template-columns: auto minmax(0, max-content) 88px auto; align-items: center; gap: 8px; color: var(--pi-text); }
|
||||
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--pi-accent); }
|
||||
input.days { box-sizing: border-box; width: 88px; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: inherit; }
|
||||
input.days { box-sizing: border-box; width: 88px; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
input.days:disabled { opacity: .55; }
|
||||
.warning, .unavailable, .dialog-error, .result { border: 1px solid var(--pi-border); border-radius: 10px; padding: 10px 12px; }
|
||||
.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionInfo, SessionStatus } from "../api";
|
||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList";
|
||||
|
||||
describe("sessionRowActivityKind", () => {
|
||||
const idle: SessionStatus = { sessionId: "s", isStreaming: false, isCompacting: false, isBashRunning: false, pendingMessageCount: 0, queuedMessages: [], tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 };
|
||||
const idle = sessionStatus("s");
|
||||
|
||||
it("reports 'sending' for an uploading session, taking precedence over server activity", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, true)).toBe("sending");
|
||||
@@ -25,6 +26,40 @@ describe("sessionRowActivityKind", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session action eligibility", () => {
|
||||
it("requires a persisted server signal before archiving when persistence is authoritative", () => {
|
||||
const authoritative = { authoritative: true };
|
||||
expect(isArchivableSessionInfo(session("persisted", { persisted: true }), undefined, authoritative)).toBe(true);
|
||||
expect(isArchivableSessionInfo(session("unknown"), undefined, authoritative)).toBe(false);
|
||||
expect(isArchivableSessionInfo(session("transient", { persisted: false }), undefined, authoritative)).toBe(false);
|
||||
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }, undefined, authoritative)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves legacy archiving when persistence support is not advertised", () => {
|
||||
expect(isArchivableSessionInfo(session("legacy"))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(session("legacy"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows deleting transient non-archived sessions from server or browser-cached signals", () => {
|
||||
expect(isTransientNewSessionInfo(session("transient", { persisted: false }))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(markCachedNewSessionInfo(session("cached")))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(session("persisted", { persisted: true }))).toBe(false);
|
||||
expect(isTransientNewSessionInfo({ ...session("archived", { persisted: false }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("uses matching status as the freshest persistence signal", () => {
|
||||
const staleTransient = session("s", { persisted: false });
|
||||
expect(isArchivableSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(false);
|
||||
|
||||
const stalePersisted = session("s", { persisted: true });
|
||||
expect(isArchivableSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(false);
|
||||
expect(isTransientNewSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(true);
|
||||
|
||||
expect(isArchivableSessionInfo(staleTransient, sessionStatus("other", { persisted: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionRowsForCurrentTree", () => {
|
||||
it("keeps archived ancestors visible while they have unarchived descendants", () => {
|
||||
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
@@ -58,6 +93,20 @@ function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
|
||||
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
|
||||
}
|
||||
|
||||
function sessionStatus(sessionId: string, overrides: Partial<SessionStatus> = {}): SessionStatus {
|
||||
return {
|
||||
sessionId,
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LitElement, css, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { shortSessionId } from "../sessionLabels";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge";
|
||||
@@ -11,7 +13,7 @@ import { listStyles } from "./shared";
|
||||
|
||||
function sessionLabel(session: SessionInfo): string {
|
||||
if (session.name !== undefined && session.name !== "") return session.name;
|
||||
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
|
||||
return session.firstMessage !== "" ? session.firstMessage : shortSessionId(session.id);
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
@@ -29,10 +31,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||
@property({ attribute: false }) sending: Record<string, true> = {};
|
||||
@property({ attribute: false }) selected?: SessionInfo;
|
||||
@property({ type: Number }) startingCount = 0;
|
||||
@property({ type: Boolean }) canStart = false;
|
||||
@property({ type: Boolean }) canDeleteArchived = false;
|
||||
@property({ type: Boolean }) canReload = false;
|
||||
@property({ type: Boolean }) canCleanup = false;
|
||||
@property({ type: Boolean }) authoritativeSessionPersistence = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@@ -109,6 +113,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
|
||||
${this.startingCount > 0 ? this.renderStartingSession() : null}
|
||||
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
|
||||
${archivedRows.length > 0 ? html`
|
||||
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
|
||||
@@ -130,7 +135,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
Sessions
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
${this.renderCleanupButton()}
|
||||
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
|
||||
${this.renderStartButton()}
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
@@ -142,7 +147,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<small class="section-count">${sessionCount}</small>
|
||||
${this.renderCleanupButton()}
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
${this.renderStartButton()}
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
@@ -157,6 +162,23 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
return html`<button class="cleanup-entry" title=${this.canCleanup ? "Preview session cleanup" : this.cleanupUnavailableMessage} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onCleanup?.(); }}>Clean up</button>`;
|
||||
}
|
||||
|
||||
private renderStartButton() {
|
||||
const title = this.startingCount > 0 ? "Start another session" : "Start a new session";
|
||||
return html`<button class="start-session-button" title=${title} aria-label=${title} ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>`;
|
||||
}
|
||||
|
||||
private renderStartingSession() {
|
||||
const plural = this.startingCount !== 1;
|
||||
return html`
|
||||
<div class="pending-session-row starting-session" role="status" aria-live="polite">
|
||||
<div class="action-main">
|
||||
<span class="action-name"><span class="activity-indicator sending" aria-hidden="true"></span>${plural ? `Starting ${String(this.startingCount)} sessions…` : "Starting session…"}</span>
|
||||
<small>Waiting for ${plural ? "new sessions" : "the new session"} to be created</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderArchivedHeading(archivedSessions: SessionInfo[]) {
|
||||
const active = this.selectionScopes.has("archived");
|
||||
return html`
|
||||
@@ -172,7 +194,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("current");
|
||||
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session));
|
||||
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
@@ -211,6 +233,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
const selectionActive = this.selectionScopes.has(scope);
|
||||
const showsCheckbox = selectionActive && canBulkSelect;
|
||||
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
|
||||
const status = this.statuses[session.id];
|
||||
const activity = this.activities[session.id];
|
||||
const persistenceOptions = this.sessionPersistenceOptions();
|
||||
const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
|
||||
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
|
||||
const canReloadSession = canArchive && this.canReload;
|
||||
return html`
|
||||
<div
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
|
||||
@@ -222,25 +250,27 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
>
|
||||
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
||||
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
||||
<span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
|
||||
<span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
|
||||
${this.renderActivity(session)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
|
||||
${this.openMenuSessionId === session.id ? html`
|
||||
<div class="action-menu-panel" style=${this.menuStyle}>
|
||||
${isCachedNewSessionInfo(session)
|
||||
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||
: session.archived === true
|
||||
? html`
|
||||
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
${session.archived === true
|
||||
? html`
|
||||
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
: canDeleteTransient
|
||||
? html`<button title="Delete transient new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||
: html`
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
${canArchive ? html`
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
` : null}
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
|
||||
${canReloadSession ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
|
||||
`}
|
||||
</div>
|
||||
` : null}
|
||||
@@ -287,7 +317,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
}
|
||||
|
||||
private archiveSelectedCurrent(): void {
|
||||
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session));
|
||||
const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
|
||||
void this.onArchiveMany?.(sessions);
|
||||
}
|
||||
@@ -366,12 +396,20 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private renderSessionMetaPrefix(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session)) return "new · ";
|
||||
private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) {
|
||||
if (isTransientNewSessionInfo(session, status, this.sessionPersistenceOptions())) {
|
||||
if (activity?.phase === "active") return "creating · ";
|
||||
if (activity?.phase === "error") return "error · ";
|
||||
return "new · ";
|
||||
}
|
||||
if (session.archived === true) return "read-only · ";
|
||||
return "";
|
||||
}
|
||||
|
||||
private sessionPersistenceOptions() {
|
||||
return { authoritative: this.authoritativeSessionPersistence };
|
||||
}
|
||||
|
||||
private renderActivity(session: SessionInfo) {
|
||||
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
|
||||
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
|
||||
@@ -381,6 +419,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
h2 { min-height: 30px; }
|
||||
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
|
||||
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
|
||||
.start-session-button { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 9px; }
|
||||
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
|
||||
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
|
||||
.bulk-row button { padding: 5px 7px; font-size: 12px; }
|
||||
@@ -391,6 +430,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
|
||||
button.danger:hover, .action-menu-panel button.danger:hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
.action-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
|
||||
.pending-session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); margin: 6px 0; cursor: default; }
|
||||
.pending-session-row.starting-session .action-main { border-radius: 8px; border-style: dashed; color: var(--pi-muted); }
|
||||
.pending-session-row.starting-session .action-name { display: flex; align-items: center; gap: 6px; max-height: none; -webkit-line-clamp: 1; }
|
||||
.pending-session-row.starting-session .activity-indicator { flex: 0 0 auto; margin: 0; }
|
||||
.action-main.selecting { padding-left: calc(32px + var(--depth, 0) * 16px); }
|
||||
.session-checkbox { position: absolute; top: 9px; left: calc(8px + var(--depth, 0) * 16px); z-index: 2; margin: 0; }
|
||||
`];
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, type PiWebConfigResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, collectTemplateStrings, configResponse, deferred, getDialogProperty, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog general settings machine targeting", () => {
|
||||
it("renders the active settings panel without the old global scope note", () => {
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.section = "general";
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const strings = collectTemplateStrings(dialog.render()).join("");
|
||||
|
||||
expect(strings).toContain("<settings-general-panel");
|
||||
expect(strings).not.toContain("scope-note");
|
||||
expect(strings).not.toContain("This tab edits:");
|
||||
});
|
||||
|
||||
it("keeps gateway server config saves on the gateway config endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
|
||||
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads file access and upload config from the selected machine", async () => {
|
||||
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
|
||||
const savedConfig = configResponse(patch);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
|
||||
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale file access load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale file access save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows selected-machine file access errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { piPackagesApi, pluginsApi, type PiPackageMutationResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, deferred, getDialogProperty, packageInfo, packageMutationResponse, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement, secondRemoteMachine } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog Pi package orchestration", () => {
|
||||
it("loads package data from the selected machine and ignores stale target responses", async () => {
|
||||
const remotePackages = { packages: [packageInfo("npm:@acme/tools")] };
|
||||
const staleLoad = deferred<typeof remotePackages>();
|
||||
const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget");
|
||||
expect(packagesSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
staleLoad.resolve(remotePackages);
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => {
|
||||
const installedPackages = [packageInfo("npm:@acme/new-tools")];
|
||||
const install = deferred<PiPackageMutationResponse>();
|
||||
const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools");
|
||||
|
||||
expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" });
|
||||
|
||||
install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools"));
|
||||
await installPromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages });
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes gateway plugins after a local package mutation", async () => {
|
||||
const updatedPackages = [packageInfo("npm:@acme/tools")];
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]);
|
||||
const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "updatePiPackage");
|
||||
|
||||
expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages });
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog plugin settings machine targeting", () => {
|
||||
it("loads plugin config and plugin list from the selected machine", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
const plugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
|
||||
stubWindowTimers();
|
||||
const baseConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const savedConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[
|
||||
{
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
},
|
||||
"remote-a",
|
||||
]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", true);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale plugin load responses after the selected machine changes", async () => {
|
||||
const configLoad = deferred<PiWebConfigResponse>();
|
||||
const pluginsLoad = deferred<PiWebPluginsResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
|
||||
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
|
||||
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale plugin save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
|
||||
await savePromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog session daemon machine targeting", () => {
|
||||
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
|
||||
const config = configResponse({ host: "127.0.0.1" });
|
||||
const plugins: PiWebPluginsResponse = { plugins: [] };
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "loadConfig");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([[]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "error")).toBe("");
|
||||
expect(getDialogProperty(dialog, "loading")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads session-daemon config from the selected machine", async () => {
|
||||
const config = configResponse({ spawnSessions: false, subsessions: true });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
||||
const savedConfig = configResponse({ spawnSessions: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ spawnSessions: false }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
|
||||
stubWindowTimers();
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
save.resolve(configResponse({ subsessions: true }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy).not.toHaveBeenCalled();
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("does not save remote selected-machine settings when runtime support is missing", async () => {
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("shows selected-machine settings errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { Machine, MachineRuntime, PiPackageInfo, PiPackageMutationResponse, PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo, PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
|
||||
export const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export const secondRemoteMachine: Machine = {
|
||||
id: "remote-b",
|
||||
name: "Build Box",
|
||||
kind: "remote",
|
||||
baseUrl: "https://build.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export const runtimeWithPackageManagement: MachineRuntime = {
|
||||
machineId: "remote-a",
|
||||
ok: true,
|
||||
checkedAt: "2026-07-01T00:00:00.000Z",
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
};
|
||||
|
||||
export function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
|
||||
return Reflect.get(dialog, property);
|
||||
}
|
||||
|
||||
export function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
|
||||
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
|
||||
}
|
||||
|
||||
export async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callDialogMethod(dialog, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
export function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
|
||||
const result = callDialogMethod(dialog, "updated", changed);
|
||||
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
|
||||
}
|
||||
|
||||
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(dialog, methodName);
|
||||
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
|
||||
return method.call(dialog, ...args);
|
||||
}
|
||||
|
||||
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
export function collectTemplateStrings(template: TemplateResult): string[] {
|
||||
const strings: string[] = [];
|
||||
visitTemplate(template);
|
||||
return strings;
|
||||
|
||||
function visitTemplate(current: TemplateResult): void {
|
||||
strings.push(...templateStrings(current));
|
||||
for (const value of templateValues(current)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
|
||||
} else if (isTemplateResult(value)) {
|
||||
visitTemplate(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
export function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function packageInfo(source: string): PiPackageInfo {
|
||||
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
|
||||
}
|
||||
|
||||
export function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse {
|
||||
return source === undefined ? { action, packages } : { action, source, packages };
|
||||
}
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
let rejectDeferred: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveDeferred = resolve;
|
||||
rejectDeferred = reject;
|
||||
});
|
||||
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
|
||||
}
|
||||
|
||||
export function stubWindowTimers(): void {
|
||||
vi.stubGlobal("window", {
|
||||
clearTimeout: vi.fn(),
|
||||
setTimeout: vi.fn(() => 1),
|
||||
});
|
||||
}
|
||||
@@ -1,31 +1,65 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { AppAction } from "../actions";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import type { SettingsSection } from "../settingsRoute";
|
||||
import "./settings/SettingsGeneralPanel";
|
||||
import "./settings/SettingsSessiondPanel";
|
||||
import "./settings/SettingsPackagesPanel";
|
||||
import "./settings/SettingsPluginsPanel";
|
||||
import "./settings/SettingsShortcutsPanel";
|
||||
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
||||
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
|
||||
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
|
||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
|
||||
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
|
||||
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
|
||||
|
||||
@customElement("settings-dialog")
|
||||
export class SettingsDialog extends LitElement {
|
||||
@property({ attribute: false }) section: SettingsSection = "general";
|
||||
@property({ attribute: false }) actions: AppAction[] = [];
|
||||
@property({ attribute: false }) machine: Machine | undefined;
|
||||
@property({ attribute: false }) machineRuntime: MachineRuntime | undefined;
|
||||
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
|
||||
@property({ attribute: false }) onClose?: () => void;
|
||||
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
||||
@state() private configResponse: PiWebConfigResponse | undefined;
|
||||
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private selectedPluginConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private selectedPluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private packagesResponse: PiPackagesResponse | undefined;
|
||||
@state() private loading = true;
|
||||
@state() private accessLoading = true;
|
||||
@state() private sessiondLoading = true;
|
||||
@state() private pluginLoading = true;
|
||||
@state() private packageLoading = true;
|
||||
@state() private saving = false;
|
||||
@state() private packageOperation: PiPackageOperationState | undefined;
|
||||
@state() private error = "";
|
||||
@state() private accessError = "";
|
||||
@state() private sessiondError = "";
|
||||
@state() private pluginError = "";
|
||||
@state() private packageError = "";
|
||||
@state() private savedMessage = "";
|
||||
@state() private packageMessage = "";
|
||||
private savedMessageTimer: number | undefined;
|
||||
private loadRequestSeq = 0;
|
||||
private accessLoadRequestSeq = 0;
|
||||
private sessiondLoadRequestSeq = 0;
|
||||
private pluginLoadRequestSeq = 0;
|
||||
private packageLoadRequestSeq = 0;
|
||||
private packageMutationSeq = 0;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.loadConfig();
|
||||
void this.loadAccessConfigForTarget();
|
||||
void this.loadSessiondConfigForTarget();
|
||||
void this.loadPluginsForTarget();
|
||||
void this.loadPackagesForTarget();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -34,6 +68,37 @@ export class SettingsDialog extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
const currentTarget = this.settingsTarget();
|
||||
if (changed.has("machine")) {
|
||||
const previousTarget = settingsMachineTarget(changed.get("machine"));
|
||||
if (previousTarget.id !== currentTarget.id) {
|
||||
this.resetAccessStateForTargetChange();
|
||||
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
|
||||
this.resetSessiondStateForTargetChange();
|
||||
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
|
||||
this.resetPluginStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
|
||||
this.resetPackageStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed.has("machineRuntime")) return;
|
||||
if (this.selectedMachineSettingsSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) {
|
||||
this.resetAccessStateForTargetChange();
|
||||
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
|
||||
this.resetSessiondStateForTargetChange();
|
||||
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
|
||||
this.resetPluginStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
|
||||
}
|
||||
if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return;
|
||||
this.resetPackageStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
|
||||
@@ -47,10 +112,11 @@ export class SettingsDialog extends LitElement {
|
||||
</header>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Server config")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
|
||||
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
||||
${this.renderNavButton("general", "General", "Gateway + selected machine")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Selected machine")}
|
||||
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
|
||||
${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
|
||||
</nav>
|
||||
<main class="settings-content">
|
||||
${this.renderActiveSection()}
|
||||
@@ -65,13 +131,14 @@ export class SettingsDialog extends LitElement {
|
||||
if (this.section === "sessiond") {
|
||||
return html`
|
||||
<settings-sessiond-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.configResponse=${this.sessiondConfigResponse}
|
||||
.loading=${this.sessiondLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.error=${this.sessiondError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadSessiondConfigForTarget()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
|
||||
></settings-sessiond-panel>
|
||||
`;
|
||||
}
|
||||
@@ -89,16 +156,34 @@ export class SettingsDialog extends LitElement {
|
||||
></settings-shortcuts-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "packages") {
|
||||
return html`
|
||||
<settings-packages-panel
|
||||
.packagesResponse=${this.packagesResponse}
|
||||
.targetMachine=${this.packageTarget()}
|
||||
.managementSupport=${this.packageManagementSupport()}
|
||||
.loading=${this.packageLoading}
|
||||
.operation=${this.packageOperation}
|
||||
.error=${this.packageError}
|
||||
.operationMessage=${this.packageMessage}
|
||||
.onReload=${() => this.loadPackagesForTarget()}
|
||||
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
|
||||
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
|
||||
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
|
||||
></settings-packages-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
<settings-plugins-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.pluginsResponse=${this.pluginsResponse}
|
||||
.loading=${this.loading}
|
||||
.configResponse=${this.selectedPluginConfigResponse}
|
||||
.pluginsResponse=${this.selectedPluginsResponse}
|
||||
.loading=${this.pluginLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.error=${this.pluginError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadPluginsForTarget()}
|
||||
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
|
||||
></settings-plugins-panel>
|
||||
`;
|
||||
@@ -106,12 +191,18 @@ export class SettingsDialog extends LitElement {
|
||||
return html`
|
||||
<settings-general-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.machineConfigResponse=${this.accessConfigResponse}
|
||||
.loading=${this.loading}
|
||||
.machineLoading=${this.accessLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.machineError=${this.accessError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onReloadMachine=${() => this.loadAccessConfigForTarget()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
.onSaveMachineConfig=${(config: PiWebConfigValues) => this.saveMachineAccessConfig(config)}
|
||||
></settings-general-panel>
|
||||
`;
|
||||
}
|
||||
@@ -131,31 +222,152 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
const requestSeq = ++this.loadRequestSeq;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
|
||||
this.configResponse = config;
|
||||
this.pluginsResponse = plugins;
|
||||
} catch (error) {
|
||||
this.error = `Failed to load settings: ${errorMessage(error)}`;
|
||||
const result = await loadGatewaySettingsData({
|
||||
loadConfig: () => configApi.config(),
|
||||
loadPlugins: () => pluginsApi.plugins(),
|
||||
});
|
||||
if (!this.isCurrentLoad(requestSeq)) return;
|
||||
|
||||
if (result.config !== undefined) this.configResponse = result.config;
|
||||
if (result.plugins !== undefined) this.pluginsResponse = result.plugins;
|
||||
this.error = result.error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (this.isCurrentLoad(requestSeq)) this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccessConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.accessLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.accessConfigResponse = undefined;
|
||||
this.accessLoading = false;
|
||||
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.accessLoading = true;
|
||||
this.accessError = "";
|
||||
try {
|
||||
const response = await configApi.config(target.id);
|
||||
if (!this.isCurrentAccessLoad(requestSeq, target)) return;
|
||||
this.accessConfigResponse = response;
|
||||
} catch (error) {
|
||||
if (this.isCurrentAccessLoad(requestSeq, target)) {
|
||||
this.accessError = `Failed to load file access/upload config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
if (this.isCurrentAccessLoad(requestSeq, target)) this.accessLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.sessiondLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.sessiondConfigResponse = undefined;
|
||||
this.sessiondLoading = false;
|
||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.sessiondLoading = true;
|
||||
this.sessiondError = "";
|
||||
try {
|
||||
const response = await configApi.config(target.id);
|
||||
if (!this.isCurrentSessiondLoad(requestSeq, target)) return;
|
||||
this.sessiondConfigResponse = response;
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessiondLoad(requestSeq, target)) {
|
||||
this.sessiondError = `Failed to load session-daemon config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
if (this.isCurrentSessiondLoad(requestSeq, target)) this.sessiondLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPluginsForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.pluginLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.selectedPluginConfigResponse = undefined;
|
||||
this.selectedPluginsResponse = undefined;
|
||||
this.pluginLoading = false;
|
||||
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.pluginLoading = true;
|
||||
this.pluginError = "";
|
||||
try {
|
||||
const [config, plugins] = await Promise.allSettled([configApi.config(target.id), pluginsApi.plugins(target.id)]);
|
||||
if (!this.isCurrentPluginLoad(requestSeq, target)) return;
|
||||
|
||||
const errors: string[] = [];
|
||||
if (config.status === "fulfilled") this.selectedPluginConfigResponse = config.value;
|
||||
else errors.push(`config: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(config.reason), target)}`);
|
||||
|
||||
if (plugins.status === "fulfilled") this.selectedPluginsResponse = plugins.value;
|
||||
else errors.push(`PI WEB plugins: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(plugins.reason), target)}`);
|
||||
|
||||
this.pluginError = errors.length === 0 ? "" : `Failed to load PI WEB plugin settings from ${settingsMachineTargetLabel(target)}: ${errors.join("; ")}`;
|
||||
} finally {
|
||||
if (this.isCurrentPluginLoad(requestSeq, target)) this.pluginLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPackagesForTarget(target = this.packageTarget()): Promise<void> {
|
||||
const requestSeq = ++this.packageLoadRequestSeq;
|
||||
this.packageLoading = true;
|
||||
this.packageError = "";
|
||||
this.packageMessage = "";
|
||||
try {
|
||||
const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId), this.packageManagementSupport(target));
|
||||
if (!this.isCurrentPackageLoad(requestSeq, target)) return;
|
||||
|
||||
this.packagesResponse = result.packagesResponse;
|
||||
this.packageError = result.error;
|
||||
} finally {
|
||||
if (this.isCurrentPackageLoad(requestSeq, target)) this.packageLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
const currentPlugins = baseConfig.plugins ?? {};
|
||||
const currentPluginConfig = currentPlugins[pluginId] ?? {};
|
||||
await this.saveConfig({
|
||||
...baseConfig,
|
||||
plugins: {
|
||||
...currentPlugins,
|
||||
[pluginId]: { ...currentPluginConfig, enabled },
|
||||
},
|
||||
});
|
||||
await this.refreshPlugins();
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
if (this.selectedPluginConfigResponse === undefined) {
|
||||
this.pluginError = `Plugin config is not loaded for ${settingsMachineTargetLabel(target)}. Reload before changing plugin enablement.`;
|
||||
return;
|
||||
}
|
||||
const patch = pluginEnabledConfigPatch(this.selectedPluginConfigResponse.config, pluginId, enabled);
|
||||
this.saving = true;
|
||||
this.pluginError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(patch, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.selectedPluginConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) {
|
||||
this.configResponse = mergeSelectedMachinePluginConfig(this.configResponse, response);
|
||||
this.onConfigSaved?.(this.configResponse.effectiveConfig);
|
||||
}
|
||||
const pluginRefreshError = await this.refreshPluginsForTarget(target);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
if (pluginRefreshError !== undefined) this.pluginError = pluginRefreshError;
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.pluginError = `Failed to save PI WEB plugin config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveConfig(config: PiWebConfigValues): Promise<void> {
|
||||
@@ -175,14 +387,226 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPlugins(): Promise<void> {
|
||||
private async saveMachineAccessConfig(config: PiWebConfigValues): Promise<void> {
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.accessError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.accessConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) {
|
||||
this.configResponse = mergeSelectedMachineAccessConfig(this.configResponse, response);
|
||||
this.onConfigSaved?.(this.configResponse.effectiveConfig);
|
||||
}
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.accessError = `Failed to save file access/upload config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSessiondConfig(config: PiWebConfigValues): Promise<void> {
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.sessiondError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.sessiondConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) this.configResponse = mergeSelectedMachineSessiondConfig(this.configResponse, response);
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.sessiondError = `Failed to save session-daemon config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async installPiPackage(source: string): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", target, () => piPackagesApi.install(source, target.id));
|
||||
}
|
||||
|
||||
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", target, () => piPackagesApi.remove(source, scope, target.id));
|
||||
}
|
||||
|
||||
private async updatePiPackage(source?: string): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", target, () => piPackagesApi.update(source, target.id));
|
||||
}
|
||||
|
||||
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
|
||||
const support = this.packageManagementSupport(target);
|
||||
if (isPiPackageManagementUnsupported(support)) {
|
||||
this.packageError = support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`;
|
||||
throw new Error(this.packageError);
|
||||
}
|
||||
if (this.saving) throw new Error("A settings operation is already running.");
|
||||
const requestSeq = ++this.packageMutationSeq;
|
||||
this.packageLoadRequestSeq += 1;
|
||||
this.packageLoading = false;
|
||||
this.saving = true;
|
||||
this.packageOperation = operation;
|
||||
this.packageError = "";
|
||||
this.packageMessage = "";
|
||||
try {
|
||||
const response = await mutate();
|
||||
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||
this.packagesResponse = { packages: response.packages };
|
||||
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshGatewayPlugins() : undefined;
|
||||
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
|
||||
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
|
||||
} catch (error) {
|
||||
if (this.isCurrentPackageMutation(requestSeq, target)) this.packageError = `Failed to ${label} on ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}`;
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.packageMutationSeq === requestSeq) {
|
||||
this.packageOperation = undefined;
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshGatewayPlugins(): Promise<string | undefined> {
|
||||
try {
|
||||
this.pluginsResponse = await pluginsApi.plugins();
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
this.error = `Failed to refresh plugins: ${errorMessage(error)}`;
|
||||
return `Failed to refresh gateway PI WEB plugins: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPluginsForTarget(target: SettingsMachineTarget): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await pluginsApi.plugins(target.id);
|
||||
if (this.isCurrentSettingsTarget(target)) this.selectedPluginsResponse = response;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return `Config saved, but failed to refresh PI WEB plugins from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private settingsTarget(): SettingsMachineTarget {
|
||||
return settingsMachineTarget(this.machine);
|
||||
}
|
||||
|
||||
private packageTarget(): PiPackageTargetContext {
|
||||
return this.settingsTarget();
|
||||
}
|
||||
|
||||
private selectedMachineSettingsSupport(target = this.settingsTarget()): SelectedMachineSettingsSupport {
|
||||
return selectedMachineSettingsSupport(target, this.machineRuntime);
|
||||
}
|
||||
|
||||
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
|
||||
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
|
||||
const currentSupport = this.selectedMachineSettingsSupport(target);
|
||||
return selectedMachineSettingsSupportKey(previousSupport) !== selectedMachineSettingsSupportKey(currentSupport);
|
||||
}
|
||||
|
||||
private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport {
|
||||
return piPackageManagementSupport(target, this.machineRuntime);
|
||||
}
|
||||
|
||||
private packageManagementSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: PiPackageTargetContext): boolean {
|
||||
const previousSupport = piPackageManagementSupport(target, previousRuntime);
|
||||
const currentSupport = this.packageManagementSupport(target);
|
||||
if (piPackageManagementSupportKey(previousSupport) === piPackageManagementSupportKey(currentSupport)) return false;
|
||||
return previousSupport.state === "unsupported" || currentSupport.state === "unsupported";
|
||||
}
|
||||
|
||||
private isCurrentLoad(requestSeq: number): boolean {
|
||||
return requestSeq === this.loadRequestSeq;
|
||||
}
|
||||
|
||||
private isCurrentAccessLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.accessLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentSessiondLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.sessiondLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPluginLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.pluginLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
|
||||
return requestSeq === this.packageLoadRequestSeq && this.isCurrentPackageTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageMutation(requestSeq: number, target: PiPackageTargetContext): boolean {
|
||||
return requestSeq === this.packageMutationSeq && this.isCurrentPackageTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageTarget(target: PiPackageTargetContext): boolean {
|
||||
return this.packageTarget().id === target.id;
|
||||
}
|
||||
|
||||
private isCurrentSettingsTarget(target: SettingsMachineTarget): boolean {
|
||||
return this.settingsTarget().id === target.id;
|
||||
}
|
||||
|
||||
private resetAccessStateForTargetChange(): void {
|
||||
this.accessLoadRequestSeq += 1;
|
||||
this.accessLoading = false;
|
||||
this.accessError = "";
|
||||
this.accessConfigResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetSessiondStateForTargetChange(): void {
|
||||
this.sessiondLoadRequestSeq += 1;
|
||||
this.sessiondLoading = false;
|
||||
this.sessiondError = "";
|
||||
this.sessiondConfigResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetPluginStateForTargetChange(): void {
|
||||
this.pluginLoadRequestSeq += 1;
|
||||
this.pluginLoading = false;
|
||||
this.pluginError = "";
|
||||
this.selectedPluginConfigResponse = undefined;
|
||||
this.selectedPluginsResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetPackageStateForTargetChange(): void {
|
||||
const hadPackageOperation = this.packageOperation !== undefined;
|
||||
this.packageLoadRequestSeq += 1;
|
||||
this.packageMutationSeq += 1;
|
||||
this.packageLoading = false;
|
||||
this.packageOperation = undefined;
|
||||
this.packageMessage = "";
|
||||
this.packageError = "";
|
||||
this.packagesResponse = undefined;
|
||||
if (hadPackageOperation) this.saving = false;
|
||||
}
|
||||
|
||||
private showSavedMessage(): void {
|
||||
this.savedMessage = "Config saved.";
|
||||
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { styleMap, type StyleInfo } from "lit/directives/style-map.js";
|
||||
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
|
||||
import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
|
||||
import { createTerminalCopySnapshot, DEFAULT_TERMINAL_ANSI_THEME, type TerminalCopyRunStyle, type TerminalCopySnapshot } from "../terminalCopySnapshot";
|
||||
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
|
||||
import "./TerminalSoftKeys";
|
||||
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
|
||||
@@ -27,6 +30,8 @@ export class TerminalPanel extends LitElement {
|
||||
@property({ type: Boolean }) autoStart = false;
|
||||
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
||||
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
|
||||
@query(".terminal-copy-content") private terminalCopyContent?: HTMLPreElement | null;
|
||||
@query(".terminal-copy-selector") private terminalCopySelector?: HTMLTextAreaElement | null;
|
||||
@state() private terminals: TerminalInfo[] = [];
|
||||
@state() private commandRuns: TerminalCommandRun[] = [];
|
||||
@state() private selectedId: string | undefined;
|
||||
@@ -37,6 +42,8 @@ export class TerminalPanel extends LitElement {
|
||||
@state() private continuingTerminalIds: string[] = [];
|
||||
@state() private defaultSoftKeysEnvironment = false;
|
||||
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
|
||||
@state() private copySnapshot: TerminalCopySnapshot | undefined;
|
||||
@state() private copyStatus: string | undefined;
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
@@ -311,7 +318,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
|
||||
this.resizeObserver.observe(terminalHost);
|
||||
terminal.onData((data) => {
|
||||
if (this.suppressTerminalInput) return;
|
||||
if (this.suppressTerminalInput || this.copySnapshot !== undefined) return;
|
||||
this.sendTerminalInput(data);
|
||||
});
|
||||
const initialSize = this.fitTerminal();
|
||||
@@ -406,6 +413,7 @@ export class TerminalPanel extends LitElement {
|
||||
}
|
||||
|
||||
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.sendTerminalInput(data);
|
||||
if (options.refocus) this.focusTerminal();
|
||||
}
|
||||
@@ -429,6 +437,8 @@ export class TerminalPanel extends LitElement {
|
||||
this.terminal?.dispose();
|
||||
this.terminal = undefined;
|
||||
this.fitAddon = undefined;
|
||||
this.copySnapshot = undefined;
|
||||
this.copyStatus = undefined;
|
||||
}
|
||||
|
||||
private renderCommandRunNotice() {
|
||||
@@ -464,20 +474,147 @@ export class TerminalPanel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
private enterCopyMode(): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.captureCopySnapshot();
|
||||
}
|
||||
|
||||
private refreshCopyMode(): void {
|
||||
if (this.copySnapshot === undefined) return;
|
||||
this.captureCopySnapshot();
|
||||
}
|
||||
|
||||
private captureCopySnapshot(): void {
|
||||
const terminal = this.terminal;
|
||||
if (terminal === undefined) return;
|
||||
const snapshot = createTerminalCopySnapshot(terminal.buffer.active, terminal.cols, {
|
||||
theme: terminal.options.theme,
|
||||
drawBoldTextInBrightColors: terminal.options.drawBoldTextInBrightColors,
|
||||
});
|
||||
this.copySnapshot = snapshot;
|
||||
this.copyStatus = undefined;
|
||||
terminal.blur();
|
||||
void this.updateComplete.then(() => {
|
||||
const selector = this.terminalCopySelector;
|
||||
if (selector === null || selector === undefined) return;
|
||||
const sourceScrollRange = Math.max(0, snapshot.physicalLineCount - terminal.rows);
|
||||
const sourceScrollTop = Math.min(sourceScrollRange, snapshot.viewportLine);
|
||||
const scrollRatio = sourceScrollRange === 0 ? 0 : sourceScrollTop / sourceScrollRange;
|
||||
selector.scrollTop = scrollRatio * Math.max(0, selector.scrollHeight - selector.clientHeight);
|
||||
this.syncCopySnapshotScroll();
|
||||
});
|
||||
}
|
||||
|
||||
private exitCopyMode(): void {
|
||||
if (this.copySnapshot === undefined) return;
|
||||
this.copySnapshot = undefined;
|
||||
this.copyStatus = undefined;
|
||||
}
|
||||
|
||||
// iOS WebKit offsets native selection hit-testing in a scrolled generic
|
||||
// overflow container. A textarea owns selection and scrolling while the
|
||||
// synchronized, noninteractive pre preserves the terminal's ANSI styling.
|
||||
// Keep its caret visible: iOS hides native selection handles with the caret.
|
||||
private syncCopySnapshotScroll(): void {
|
||||
const selector = this.terminalCopySelector;
|
||||
const content = this.terminalCopyContent;
|
||||
if (selector === null || selector === undefined || content === null || content === undefined) return;
|
||||
const selectorVerticalRange = Math.max(0, selector.scrollHeight - selector.clientHeight);
|
||||
const contentVerticalRange = Math.max(0, content.scrollHeight - content.clientHeight);
|
||||
const selectorHorizontalRange = Math.max(0, selector.scrollWidth - selector.clientWidth);
|
||||
const contentHorizontalRange = Math.max(0, content.scrollWidth - content.clientWidth);
|
||||
content.scrollTop = normalizedScrollOffset(selector.scrollTop, selectorVerticalRange, contentVerticalRange);
|
||||
content.scrollLeft = normalizedScrollOffset(selector.scrollLeft, selectorHorizontalRange, contentHorizontalRange);
|
||||
}
|
||||
|
||||
private async copyAllSnapshotText(): Promise<void> {
|
||||
const text = this.copySnapshot?.text ?? "";
|
||||
if (text === "") {
|
||||
this.copyStatus = "No terminal output to copy.";
|
||||
return;
|
||||
}
|
||||
this.copyStatus = await writeClipboardText(text) ? "Copied all terminal output." : "Unable to copy terminal output.";
|
||||
}
|
||||
|
||||
private renderCopyModeToggle() {
|
||||
if (this.selectedId === undefined) return null;
|
||||
const active = this.copySnapshot !== undefined;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class=${active ? "copy-mode-toggle selected" : "copy-mode-toggle"}
|
||||
title=${active ? "Return to the interactive terminal" : "Select and copy terminal output"}
|
||||
aria-label=${active ? "Close terminal copy mode" : "Open terminal copy mode"}
|
||||
aria-pressed=${String(active)}
|
||||
@click=${() => { if (active) this.exitCopyMode(); else this.enterCopyMode(); }}
|
||||
>
|
||||
<span>${active ? "Done" : "Select"}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCopyModeToolbar() {
|
||||
const snapshot = this.copySnapshot;
|
||||
if (snapshot === undefined) return null;
|
||||
return html`
|
||||
<div class="terminal-copy-toolbar" role="toolbar" aria-label="Terminal copy controls">
|
||||
<span aria-live="polite">${this.copyStatus ?? "Snapshot · long-press and select text"}</span>
|
||||
<small>${snapshot.physicalLineCount} ${snapshot.physicalLineCount === 1 ? "row" : "rows"}</small>
|
||||
<button type="button" @click=${() => { this.refreshCopyMode(); }}>Refresh</button>
|
||||
<button type="button" @click=${() => { void this.copyAllSnapshotText(); }}>Copy all</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCopyMode() {
|
||||
const snapshot = this.copySnapshot;
|
||||
if (snapshot === undefined) return null;
|
||||
return html`
|
||||
<section class="terminal-copy-view" aria-label="Terminal copy mode">
|
||||
${this.copyToolbarReplacesSoftKeys() ? null : this.renderCopyModeToolbar()}
|
||||
<div class="terminal-copy-layers">
|
||||
<pre class="terminal-copy-content" aria-hidden="true">${snapshot.lines.map((line, index) => html`${index === 0 ? null : "\n"}${line.runs.map((run) => html`<span style=${styleMap(terminalCopyRunStyle(run.style))}>${run.text}</span>`)}`)}</pre>
|
||||
<textarea
|
||||
class="terminal-copy-selector"
|
||||
readonly
|
||||
inputmode="none"
|
||||
wrap="soft"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
aria-label="Selectable terminal output"
|
||||
.value=${snapshot.text}
|
||||
@scroll=${() => { this.syncCopySnapshotScroll(); }}
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectedTerminalAcceptsInput(): boolean {
|
||||
const terminal = this.selectedTerminalInfo();
|
||||
return terminal !== undefined && !terminal.exited;
|
||||
}
|
||||
|
||||
private copyToolbarReplacesSoftKeys(): boolean {
|
||||
return this.copySnapshot !== undefined && this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private renderTerminalAccessoryBar() {
|
||||
if (this.copySnapshot !== undefined) return this.copyToolbarReplacesSoftKeys() ? this.renderCopyModeToolbar() : null;
|
||||
return this.shouldShowSoftKeys() ? this.renderSoftKeys() : null;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeys(): boolean {
|
||||
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeysToggle(): boolean {
|
||||
return this.selectedTerminalAcceptsInput();
|
||||
return this.copySnapshot === undefined && this.selectedTerminalAcceptsInput();
|
||||
}
|
||||
|
||||
private toggleSoftKeys(): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.softKeysEnabled = !this.softKeysEnabled;
|
||||
this.softKeysPreferenceStored = true;
|
||||
writeTerminalSoftKeysPreference(this.softKeysEnabled);
|
||||
@@ -518,6 +655,7 @@ export class TerminalPanel extends LitElement {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
<div class="terminal-tabs">
|
||||
${this.renderCopyModeToggle()}
|
||||
${this.renderSoftKeysToggle()}
|
||||
${this.terminals.map((terminal) => html`
|
||||
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
|
||||
@@ -529,9 +667,12 @@ export class TerminalPanel extends LitElement {
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.renderCommandRunNotice()}
|
||||
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
|
||||
${this.renderTerminalAccessoryBar()}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
<div class="terminal-stage">
|
||||
<div class=${this.copySnapshot === undefined ? "terminal-host" : "terminal-host copying"} ?inert=${this.copySnapshot !== undefined}></div>
|
||||
${this.renderCopyMode()}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -540,11 +681,19 @@ export class TerminalPanel extends LitElement {
|
||||
:host { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
.terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
.terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow: auto; }
|
||||
.terminal-tabs > button { box-sizing: border-box; height: 30px; line-height: 16px; }
|
||||
/* Desktop xterm already has mouse selection and hardware keys; keep touch controls to touch/narrow layouts. */
|
||||
.copy-mode-toggle, .soft-keys-toggle, terminal-soft-keys { display: none; }
|
||||
.copy-mode-toggle.selected { display: inline-flex; }
|
||||
@media (pointer: coarse), (max-width: 760px) {
|
||||
.copy-mode-toggle, .soft-keys-toggle { display: inline-flex; }
|
||||
terminal-soft-keys { display: block; }
|
||||
}
|
||||
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
button.new { flex: 0 0 auto; color: var(--pi-muted); }
|
||||
.soft-keys-toggle { flex: 0 0 auto; }
|
||||
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
.soft-keys-toggle .keyboard-icon { display: block; flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: var(--pi-danger); }
|
||||
@@ -558,7 +707,20 @@ export class TerminalPanel extends LitElement {
|
||||
.command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice button { justify-self: end; max-width: none; }
|
||||
.terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
.terminal-host { position: absolute; inset: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-host.copying { visibility: hidden; pointer-events: none; }
|
||||
.terminal-copy-view { position: absolute; inset: 0; display: flex; flex-direction: column; min-height: 0; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); }
|
||||
.terminal-copy-toolbar { box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 47px; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
||||
.terminal-copy-toolbar > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.terminal-copy-toolbar small { margin-left: auto; white-space: nowrap; color: var(--pi-dim); }
|
||||
.terminal-copy-toolbar button { flex: 0 0 auto; width: auto; min-height: 34px; padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.terminal-copy-layers { flex: 1 1 auto; min-height: 0; display: grid; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
/* xterm renders the configured 13px terminal font in 17px-high cells. */
|
||||
.terminal-copy-content, .terminal-copy-selector { grid-area: 1 / 1; box-sizing: border-box; min-width: 0; min-height: 0; width: 100%; height: 100%; margin: 0; padding: 6px; border: 0; border-radius: 0; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 17px; letter-spacing: normal; font-variant-ligatures: none; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.terminal-copy-content { overflow: auto; pointer-events: none; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); -webkit-user-select: none; user-select: none; }
|
||||
.terminal-copy-selector { z-index: 1; overflow: auto; resize: none; outline: none; appearance: none; background: transparent; color: transparent; caret-color: var(--pi-accent); -webkit-text-fill-color: transparent; cursor: text; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; touch-action: auto; }
|
||||
.terminal-copy-selector::selection { background: var(--pi-terminal-selection); color: transparent; -webkit-text-fill-color: transparent; }
|
||||
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
|
||||
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
|
||||
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
|
||||
@@ -582,6 +744,30 @@ export class TerminalPanel extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizedScrollOffset(sourceOffset: number, sourceRange: number, targetRange: number): number {
|
||||
if (sourceRange <= 0 || targetRange <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, sourceOffset / sourceRange)) * targetRange;
|
||||
}
|
||||
|
||||
function dimTerminalCopyColor(color: string): string {
|
||||
return /^#[\da-f]{6}$/i.test(color) ? `${color}80` : `color-mix(in srgb, ${color} 50%, transparent)`;
|
||||
}
|
||||
|
||||
function terminalCopyRunStyle(style: TerminalCopyRunStyle): StyleInfo {
|
||||
const decorations = [
|
||||
style.underline ? "underline" : undefined,
|
||||
style.strikethrough ? "line-through" : undefined,
|
||||
style.overline ? "overline" : undefined,
|
||||
].filter((decoration): decoration is string => decoration !== undefined).join(" ");
|
||||
return {
|
||||
color: style.invisible ? "transparent" : style.dim ? dimTerminalCopyColor(style.foreground) : style.foreground,
|
||||
backgroundColor: style.background,
|
||||
fontWeight: style.bold ? "700" : undefined,
|
||||
fontStyle: style.italic ? "italic" : undefined,
|
||||
textDecorationLine: decorations === "" ? undefined : decorations,
|
||||
};
|
||||
}
|
||||
|
||||
interface TerminalSize {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -631,6 +817,7 @@ function terminalOptions(element: HTMLElement): ITerminalOptions {
|
||||
|
||||
function terminalTheme(element: HTMLElement): ITheme {
|
||||
return {
|
||||
...DEFAULT_TERMINAL_ANSI_THEME,
|
||||
background: themeColor(element, "--pi-terminal-bg", "#05070a"),
|
||||
foreground: themeColor(element, "--pi-terminal-text", "#e6edf3"),
|
||||
cursor: themeColor(element, "--pi-accent", "#58a6ff"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user