Merge branch 'main' into review-issue-48

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 19:48:21 +02:00
committed by GitHub
50 changed files with 5012 additions and 459 deletions
@@ -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.
@@ -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.
+5
View File
@@ -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.
+20 -2
View File
@@ -36,12 +36,22 @@ 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 \
&& ln -sf /workspace/node_modules/.bin/pi /usr/local/bin/pi \
&& 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 /workspace/node_modules /data \
&& 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
@@ -49,6 +59,8 @@ 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 \
@@ -56,9 +68,15 @@ RUN bash -euxo pipefail -c '\
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
+7 -7
View File
@@ -73,7 +73,7 @@ From a production/runtime install directory, run `./pi-web-docker <command>`. Fr
| `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. |
| `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. |
@@ -281,6 +281,10 @@ 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
@@ -327,13 +331,9 @@ Use this shared directory to switch between runtime and dev mode, not to run bot
For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Linux, prefer host-mounted paths such as `/home/core/<repo>`, `/srv/<project>`, or `/opt/<project>`. On Mac, prefer paths under `/Users/<you>/...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path.
When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree:
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.
```bash
./docker/pi-web-docker --dev stop
docker volume rm pi-web-dev_node_modules
./docker/pi-web-docker --dev start
```
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
+2 -2
View File
@@ -56,14 +56,14 @@ services:
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-data-volume
volumes: *pi-web-dev-volumes
sessiond:
build: *pi-web-dev-build
+56
View File
@@ -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."
+61
View File
@@ -25,6 +25,7 @@ Commands:
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
@@ -229,6 +230,60 @@ enforce_dev_root_safety() {
[ "$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:-}
@@ -401,6 +456,7 @@ run_runtime_host_update() {
run_update() {
assert_no_args update "$@"
require_clean_dev_update_checkout
case "$(docker_mode)" in
runtime)
if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then
@@ -729,6 +785,11 @@ run_restart_or_update() {
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
+12 -8
View File
@@ -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/&lt;uid&gt;</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>
+2 -2
View File
@@ -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>
+6 -2
View File
@@ -112,8 +112,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,6 +137,7 @@
<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>
+5 -2
View File
@@ -250,11 +250,14 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<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">
+15 -3
View File
@@ -202,9 +202,11 @@ Built-in plugins can be managed from **Settings → PI WEB plugins** or with the
### 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 → PI WEB 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
{
@@ -347,7 +349,15 @@ A plugin can fetch its own static assets with URLs under:
/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
@@ -466,6 +476,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>;
@@ -480,6 +491,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
@@ -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,
};
}
+12
View File
@@ -143,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>
@@ -155,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",
+44 -1
View File
@@ -2,7 +2,14 @@ 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 { commandWithVersionCheck, isCliEntrypoint } from "./cli.js";
import {
commandWithVersionCheck,
doctorExitCode,
isCliEntrypoint,
launchdRuntimeDetails,
regularFileExists,
serviceBackendForPlatform,
} from "./cli.js";
const originalShell = process.env["SHELL"];
@@ -33,6 +40,42 @@ describe("commandWithVersionCheck", () => {
});
});
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);
+331 -363
View File
@@ -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, 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;
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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,18 +759,6 @@ 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 commandCheck(command: string): string {
return `command -v ${command}`;
}
@@ -917,29 +778,13 @@ function nodeVersionCheck(): string {
].join(" && ");
}
function doctorChecks(): Check[] {
function generalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const backend = currentServiceBackend();
if (backend === undefined) {
return [
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`Caller login ${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
];
}
const checks: Check[] = [
...backendAvailabilityChecks(backend),
...baseShellChecks(backend),
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
];
const executables = resolveServiceExecutables(backend);
checks.push(...executables.web.checks, ...executables.sessiond.checks);
if (backend.kind === "systemd") {
checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]);
}
return checks;
}
function runChecks(checks: Check[]): boolean {
@@ -966,10 +811,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 {
@@ -989,8 +831,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.");
@@ -1005,21 +954,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) {
@@ -1037,17 +1001,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 {
+35 -11
View File
@@ -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",
@@ -32,17 +46,7 @@ afterEach(() => {
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");
@@ -50,6 +54,26 @@ describe("machine-scoped runtime API", () => {
expect(fetchCall(fetchMock, 0)[0]).toBe("/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("/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("/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 () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
+6 -1
View File
@@ -99,8 +99,13 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
}
function piWebStatusUrl(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),
piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse),
checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
};
@@ -28,6 +28,7 @@ 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)),
+9 -13
View File
@@ -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";
@@ -132,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); },
@@ -298,7 +304,7 @@ export class PiWebApp extends LitElement {
this.clearScheduledPiWebStatusRefresh();
this.piWebStatusDeferredTimer = window.setTimeout(() => {
this.piWebStatusDeferredTimer = undefined;
void this.refreshPiWebStatus();
void this.piWebStatusController.refresh();
}, delayMs);
}
@@ -308,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);
@@ -1573,6 +1568,7 @@ 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.startSessionAndOpenChat()),
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from "vitest";
import type { Machine, PiWebReleaseStatus, PiWebStatusResponse } from "../api";
import { initialAppState, type AppState } from "../appState";
import { PiWebStatusController, type PiWebStatusControllerDependencies } from "./piWebStatusController";
type StatusApi = NonNullable<PiWebStatusControllerDependencies["api"]>;
describe("PiWebStatusController", () => {
it("targets the selected machine and applies refreshed status", async () => {
const harness = createHarness("remote-a");
harness.piWebStatus.mockResolvedValue(status("remote"));
await harness.controller.refresh();
expect(harness.piWebStatus).toHaveBeenCalledWith("remote-a");
expect(harness.state().piWebStatus?.generatedAt).toBe("remote");
});
it("does not let an older periodic response overwrite a forced response", async () => {
const harness = createHarness();
const regular = createDeferred<PiWebStatusResponse>();
const forced = createDeferred<PiWebStatusResponse>();
harness.piWebStatus.mockReturnValue(regular.promise);
harness.checkForUpdates.mockReturnValue(forced.promise);
const regularRequest = harness.controller.refresh();
const forcedRequest = harness.controller.checkForUpdates();
forced.resolve(status("forced"));
await forcedRequest;
regular.resolve(status("regular"));
await regularRequest;
expect(harness.state().piWebStatus?.generatedAt).toBe("forced");
});
it("deduplicates forced checks and suppresses periodic refresh while one is pending", async () => {
const harness = createHarness();
const forced = createDeferred<PiWebStatusResponse>();
harness.checkForUpdates.mockReturnValue(forced.promise);
const first = harness.controller.checkForUpdates();
const second = harness.controller.checkForUpdates();
await harness.controller.refresh();
expect(second).toBe(first);
expect(harness.checkForUpdates).toHaveBeenCalledOnce();
expect(harness.piWebStatus).not.toHaveBeenCalled();
forced.resolve(status("forced"));
await first;
});
it("does not apply a response or error after the selected machine changes", async () => {
const harness = createHarness("remote-a");
const forced = createDeferred<PiWebStatusResponse>();
harness.checkForUpdates.mockReturnValue(forced.promise);
const request = harness.controller.checkForUpdates();
harness.selectMachine("remote-b");
forced.resolve(status("remote-a", { error: "registry unavailable" }));
await expect(request).resolves.toBeUndefined();
expect(harness.state().piWebStatus).toBeUndefined();
});
it.each([
[{ error: "registry unavailable" }, "PI WEB update check failed: registry unavailable"],
[{ skipped: true }, "PI WEB update check was skipped"],
] as const)("applies status and rejects an unsuccessful manual check", async (release, message) => {
const harness = createHarness();
harness.checkForUpdates.mockResolvedValue(status("checked", release));
await expect(harness.controller.checkForUpdates()).rejects.toThrow(message);
expect(harness.state().piWebStatus?.generatedAt).toBe("checked");
});
it("clears current status and reports periodic refresh failures", async () => {
const harness = createHarness();
const error = new Error("offline");
harness.setStatus(status("old"));
harness.piWebStatus.mockRejectedValue(error);
await harness.controller.refresh();
expect(harness.state().piWebStatus).toBeUndefined();
expect(harness.onRefreshError).toHaveBeenCalledWith("local", error);
});
});
function createHarness(machineId = "local") {
let state: AppState = { ...initialAppState(), selectedMachine: machine(machineId) };
const piWebStatus = vi.fn<StatusApi["piWebStatus"]>();
const checkForUpdates = vi.fn<StatusApi["checkForUpdates"]>();
const onRefreshError = vi.fn<(machineId: string, error: unknown) => void>();
const controller = new PiWebStatusController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { piWebStatus, checkForUpdates }, onRefreshError },
);
return {
controller,
piWebStatus,
checkForUpdates,
onRefreshError,
state: () => state,
setStatus: (piWebStatusValue: PiWebStatusResponse) => { state = { ...state, piWebStatus: piWebStatusValue }; },
selectMachine: (id: string) => { state = { ...state, selectedMachine: machine(id) }; },
};
}
function machine(id: string): Machine {
return {
id,
name: id,
kind: id === "local" ? "local" : "remote",
...(id === "local" ? {} : { baseUrl: `https://${id}.example.test` }),
createdAt: "now",
updatedAt: "now",
};
}
function status(generatedAt: string, release: Partial<PiWebReleaseStatus> = {}): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false, ...release },
commands: {},
messages: [],
};
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
@@ -0,0 +1,68 @@
import { piWebApi, type PiWebStatusResponse } from "../api";
import { selectedMachineId, type GetState, type SetState } from "./types";
export interface PiWebStatusControllerDependencies {
api?: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
onRefreshError?: (machineId: string, error: unknown) => void;
}
export class PiWebStatusController {
private readonly api: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
private readonly onRefreshError: (machineId: string, error: unknown) => void;
private requestSequence = 0;
private pendingUpdateCheck: { machineId: string; requestSequence: number; promise: Promise<void> } | undefined;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
dependencies: PiWebStatusControllerDependencies = {},
) {
this.api = dependencies.api ?? piWebApi;
this.onRefreshError = dependencies.onRefreshError ?? (() => undefined);
}
async refresh(): Promise<void> {
const machineId = selectedMachineId(this.getState());
if (this.pendingUpdateCheck?.machineId === machineId) return;
const requestSequence = ++this.requestSequence;
try {
const piWebStatus = await this.api.piWebStatus(machineId);
if (this.isCurrent(machineId, requestSequence)) this.setState({ piWebStatus });
} catch (error) {
if (!this.isCurrent(machineId, requestSequence)) return;
this.setState({ piWebStatus: undefined });
this.onRefreshError(machineId, error);
}
}
checkForUpdates(): Promise<void> {
const machineId = selectedMachineId(this.getState());
const existing = this.pendingUpdateCheck;
if (existing?.machineId === machineId) return existing.promise;
const requestSequence = ++this.requestSequence;
const promise = this.api.checkForUpdates(machineId)
.then((piWebStatus) => {
if (!this.isCurrent(machineId, requestSequence)) return;
this.setState({ piWebStatus });
throwForUnsuccessfulReleaseCheck(piWebStatus);
})
.catch((error: unknown) => {
if (this.isCurrent(machineId, requestSequence)) throw error;
})
.finally(() => {
if (this.pendingUpdateCheck?.requestSequence === requestSequence) this.pendingUpdateCheck = undefined;
});
this.pendingUpdateCheck = { machineId, requestSequence, promise };
return promise;
}
private isCurrent(machineId: string, requestSequence: number): boolean {
return selectedMachineId(this.getState()) === machineId && requestSequence === this.requestSequence;
}
}
function throwForUnsuccessfulReleaseCheck(status: PiWebStatusResponse): void {
if (status.release.error !== undefined) throw new Error(`PI WEB update check failed: ${status.release.error}`);
if (status.release.skipped === true) throw new Error("PI WEB update check was skipped because remote version checks are disabled by offline/version-check settings");
}
+1
View File
@@ -112,6 +112,7 @@ export interface PluginRuntimeContext {
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
checkForPiWebUpdates?: () => void | Promise<void>;
reloadPage: () => void;
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
startSession: () => void | Promise<void>;
+392
View File
@@ -0,0 +1,392 @@
import { describe, expect, it } from "vitest";
import {
formatNativeServiceDoctorResult,
inferInstalledNativeServiceMode,
inspectInstalledDevelopmentServiceInput,
inspectInstalledProductionServiceContext,
runNativeServiceDoctor,
type InstalledNativeServiceDefinition,
type NativeServiceDoctorTarget,
} from "./serviceDoctor.js";
import {
createDevelopmentNativeServicePlan,
type NativeServiceAuthoritativeProbe,
type NativeServicePlan,
type ProductionNativeServicePlanInput,
} from "./servicePlan.js";
import { renderLaunchdPlist, renderSystemdUnit } from "./serviceRendering.js";
const shell = {
name: "zsh",
executable: "/bin/zsh",
source: "detected",
detectedExecutable: "/bin/zsh",
} as const;
function productionInput(configured = false): ProductionNativeServicePlanInput {
return {
backend: { kind: "systemd", label: "systemd user services" },
shell,
environment: { PI_WEB_CONFIG: "/home/user/config.json" },
executables: {
sessiond: {
configuredCommand: configured ? "custom sessiond --flag" : undefined,
namedCommand: "pi-web-sessiond",
bundledEntrypointPath: "/package/sessiond.js",
},
web: {
configuredCommand: configured ? "custom web --flag" : undefined,
namedCommand: "pi-web-server",
bundledEntrypointPath: "/package/server.js",
},
},
};
}
function probeWithStatus(status: "satisfied" | "unsatisfied"): NativeServiceAuthoritativeProbe {
return {
run: (request) => Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status,
detail: status === "satisfied" ? null : `${prerequisite.id} missing in manager context`,
})),
}),
};
}
function developmentPlan(kind: "systemd" | "launchd"): NativeServicePlan {
return createDevelopmentNativeServicePlan({
backend: { kind, label: kind },
shell,
environment: { PI_WEB_CONFIG: "/home/user/config & dev.json" },
workingDirectory: "/checkout with space",
packageJsonPath: "/checkout with space/package.json",
});
}
function renderedDefinitions(plan: NativeServicePlan): InstalledNativeServiceDefinition[] {
return plan.services.map((service) => ({
id: service.id,
contents: plan.backend.kind === "systemd"
? renderSystemdUnit(plan, service)
: renderLaunchdPlist(plan, service, "/tmp/logs"),
}));
}
describe("installed native-service mode and definition inspection", () => {
it("infers production, development, absent, and ambiguous service sets", () => {
expect(inferInstalledNativeServiceMode(new Set())).toBe("none");
expect(inferInstalledNativeServiceMode(new Set(["sessiond", "web"]))).toBe("production");
expect(inferInstalledNativeServiceMode(new Set(["sessiond", "uiDev"]))).toBe("development");
expect(inferInstalledNativeServiceMode(new Set(["web", "uiDev"]))).toBe("ambiguous");
expect(inferInstalledNativeServiceMode(new Set(["sessiond"]))).toBe("ambiguous");
});
it.each(["systemd", "launchd"] as const)("reconstructs POSIX development paths from %s definitions on every host", (kind) => {
const plan = developmentPlan(kind);
expect(inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan))).toEqual({
ok: true,
value: {
backend: plan.backend,
shell,
environment: { PI_WEB_CONFIG: "/home/user/config & dev.json" },
workingDirectory: "/checkout with space",
packageJsonPath: "/checkout with space/package.json",
},
});
});
it("reconstructs escaped systemd paths, substitutions, and line controls exactly", () => {
const plan = createDevelopmentNativeServicePlan({
backend: { kind: "systemd", label: "systemd" },
shell: {
name: "zsh",
executable: "/shell $HOME/%h/zsh",
source: "detected",
detectedExecutable: "/shell $HOME/%h/zsh",
},
environment: { PI_WEB_CONFIG: "/config/%h\nnext" },
workingDirectory: "/checkout %h\nnext",
packageJsonPath: "/checkout %h\nnext/package.json",
});
expect(inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan))).toEqual({
ok: true,
value: {
backend: plan.backend,
shell: plan.shell,
environment: plan.services[0]?.environment,
workingDirectory: "/checkout %h\nnext",
packageJsonPath: "/checkout %h\nnext/package.json",
},
});
});
it("interprets installed shell executable paths with POSIX semantics", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace('"/bin/zsh"', '"/bin/not-zsh\\\\zsh"'),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected the POSIX shell basename inspection to fail");
expect(inspection.message).toContain("unsupported login shell");
});
it("inspects legacy systemd definitions without /usr/bin/env or quoted working directories", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents
.replace("ExecStart=/usr/bin/env ", "ExecStart=")
.replace("WorkingDirectory=/checkout\\x20with\\x20space", "WorkingDirectory=/checkout with space"),
}));
expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions)).toMatchObject({
ok: true,
value: { workingDirectory: "/checkout with space" },
});
});
it("rejects quoted systemd working directories that the manager treats as non-absolute", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace(
"WorkingDirectory=/checkout\\x20with\\x20space",
'WorkingDirectory="/checkout with space"',
),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected quoted working directory inspection to fail");
expect(inspection.message).toContain("invalid quoted working directory");
});
it("rejects unconsumed systemd environment syntax rather than checking a different context", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace("[Service]\n", "[Service]\nEnvironment=PATH=/custom/bin\n"),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected systemd environment inspection to fail");
expect(inspection.message).toContain("environment entry");
});
it.each([
'Environment="PI_WEB_CONFIG=/config" "PATH=/broken"',
"EnvironmentFile=/tmp/pi-web.env",
])("rejects noncanonical systemd environment context: %s", (directive) => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace("[Service]\n", `[Service]\n${directive}\n`),
}));
expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions).ok).toBe(false);
});
it("rejects duplicate systemd ExecStart directives", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace(
"Restart=no",
'ExecStart=/usr/bin/env "/bin/zsh" -lc "exec true"\nRestart=no',
),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected duplicate ExecStart inspection to fail");
expect(inspection.message).toContain("exactly one recognized ExecStart");
});
it("rejects malformed launchd environment dictionaries rather than dropping entries", () => {
const plan = developmentPlan("launchd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace(
" </dict>\n <key>RunAtLoad</key>",
" <key>BROKEN</key>\n <integer>1</integer>\n </dict>\n <key>RunAtLoad</key>",
),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected launchd environment inspection to fail");
expect(inspection.message).toContain("environment dictionary");
});
it("rejects a modified development command rather than claiming to check the installed plan", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan);
const firstDefinition = definitions[0];
if (firstDefinition === undefined) throw new Error("Expected a rendered service definition");
definitions[0] = {
...firstDefinition,
contents: firstDefinition.contents.replace("exec npm run start:sessiond", "exec npm run something-else"),
};
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected development inspection to fail");
expect(inspection.message).toContain("does not match the canonical development plan");
});
it("recovers production shell and environment while leaving executable strategy prospective", () => {
const plan = developmentPlan("launchd");
const firstService = plan.services[0];
if (firstService === undefined) throw new Error("Expected a development service");
const productionService = { ...firstService, workingDirectory: null };
const productionPlan: NativeServicePlan = { ...plan, mode: "production", services: [productionService] };
const productionLike = [{
id: "sessiond" as const,
contents: renderLaunchdPlist(productionPlan, productionService, "/tmp/logs"),
}];
expect(inspectInstalledProductionServiceContext(plan.backend, productionLike)).toEqual({
ok: true,
value: {
shell,
environment: { PI_WEB_CONFIG: "/home/user/config & dev.json" },
},
});
});
});
describe("native-service doctor planning and reporting", () => {
it("validates installed development requirements without production binary checks", async () => {
const plan = developmentPlan("launchd");
const inspected = inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan));
if (!inspected.ok) throw new Error(inspected.message);
const result = await runNativeServiceDoctor(
{ kind: "installed-development", input: inspected.value },
{ probe: probeWithStatus("satisfied"), fileExists: () => false },
);
const report = formatNativeServiceDoctorResult(result);
expect(report.ok).toBe(true);
expect(report.lines).toContain("Installed development native-service plan:");
expect(report.plan?.services.flatMap((service) => service.prerequisites)).not.toEqual(
expect.arrayContaining([expect.objectContaining({ command: "pi-web-server" })]),
);
});
it("does not recommend PATH changes for checkout metadata failures", async () => {
const plan = developmentPlan("systemd");
const inspected = inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan));
if (!inspected.ok) throw new Error(inspected.message);
const result = await runNativeServiceDoctor(
{ kind: "installed-development", input: inspected.value },
{
probe: {
run: (request) => Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status: prerequisite.kind === "package-scripts" ? "unsatisfied" as const : "satisfied" as const,
detail: prerequisite.kind === "package-scripts" ? "scripts missing" : null,
})),
}),
},
fileExists: () => true,
},
);
const report = formatNativeServiceDoctorResult(result);
expect(report).toMatchObject({ ok: false, failureKind: "requirements", pathAdviceRecommended: false });
});
it("labels a production check as prospective and reports manager-context requirements", async () => {
const target: NativeServiceDoctorTarget = {
kind: "prospective-production",
input: productionInput(),
reason: "installed executable strategy is not recorded",
};
const result = await runNativeServiceDoctor(target, {
probe: probeWithStatus("unsatisfied"),
fileExists: () => true,
});
const report = formatNativeServiceDoctorResult(result);
expect(report.ok).toBe(false);
expect(report.failureKind).toBe("requirements");
expect(report.lines[0]).toContain("Prospective production native-service plan");
expect(report.lines.join("\n")).toContain("Native service requirement failed");
expect(report.failedPrerequisites).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "node-version" }),
expect.objectContaining({ kind: "readable-file" }),
]));
});
it("retains the installed production shell when resolution fails before a plan exists", async () => {
const result = await runNativeServiceDoctor(
{ kind: "prospective-production", input: productionInput(), reason: "installed strategy is unknown" },
{ probe: probeWithStatus("unsatisfied"), fileExists: () => false },
);
const report = formatNativeServiceDoctorResult(result);
expect(report).toMatchObject({
ok: false,
failureKind: "requirements",
plan: null,
adviceShell: shell,
pathAdviceRecommended: true,
});
});
it("preserves configured overrides as unverified and does not probe arbitrary commands", async () => {
let calls = 0;
const result = await runNativeServiceDoctor(
{ kind: "prospective-production", input: productionInput(true), reason: "current configured overrides" },
{
probe: { run: () => { calls += 1; return Promise.resolve({ kind: "completed", outcomes: [] }); } },
fileExists: () => false,
},
);
const report = formatNativeServiceDoctorResult(result);
expect(calls).toBe(1);
expect(report.ok).toBe(true);
expect(report.lines.join("\n")).toContain("does not execute arbitrary configured commands");
});
it.each(["manager", "timeout", "malformed-output", "cleanup"] as const)(
"distinguishes %s infrastructure failures from PATH requirement drift",
async (reason) => {
const result = await runNativeServiceDoctor(
{ kind: "prospective-production", input: productionInput(), reason: "no installed services" },
{
probe: { run: () => Promise.resolve({ kind: "infrastructure-failure", reason, message: `${reason} failure` }) },
fileExists: () => true,
},
);
const report = formatNativeServiceDoctorResult(result);
expect(report.ok).toBe(false);
expect(report.failureKind).toBe("infrastructure");
expect(report.lines.join("\n")).toContain(`infrastructure failure (${reason})`);
expect(report.lines.join("\n")).toContain("not proof of a PATH mismatch");
},
);
it("makes mixed or malformed installed definitions a failing inspection result", async () => {
const result = await runNativeServiceDoctor(
{ kind: "inspection-failure", message: "production and development service IDs are both installed" },
{ probe: probeWithStatus("satisfied"), fileExists: () => true },
);
const report = formatNativeServiceDoctorResult(result);
expect(report).toMatchObject({ ok: false, failureKind: "inspection" });
expect(report.lines.join("\n")).toContain("could not be inspected");
});
});
+591
View File
@@ -0,0 +1,591 @@
import { posix as posixPath } from "node:path";
import {
createDevelopmentNativeServicePlan,
nativeServicePrerequisiteNeedsPathAdvice,
resolveProductionNativeServicePlan,
validateNativeServicePlan,
type DevelopmentNativeServicePlanInput,
type NativeServiceBackend,
type NativeServiceId,
type NativeServicePlan,
type NativeServicePlanDependencies,
type NativeServicePlanFailure,
type NativeServicePlanValidationFailure,
type NativeServicePrerequisite,
type NativeServiceShell,
type ProductionNativeServicePlanInput,
} from "./servicePlan.js";
export type InstalledNativeServiceMode = "none" | "production" | "development" | "ambiguous";
export interface InstalledNativeServiceDefinition {
id: NativeServiceId;
contents: string;
}
export interface InstalledNativeServiceContext {
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
}
export type InstalledNativeServiceInspection<T> =
| { ok: true; value: T }
| { ok: false; message: string };
export type NativeServiceDoctorTarget =
| {
kind: "installed-development";
input: DevelopmentNativeServicePlanInput;
}
| {
kind: "prospective-production";
input: ProductionNativeServicePlanInput;
reason: string;
}
| {
kind: "inspection-failure";
message: string;
};
interface NativeServiceDoctorScope {
kind: "installed-development" | "prospective-production";
reason: string | null;
shell: NativeServiceShell;
}
export type NativeServiceDoctorResult =
| {
kind: "inspection-failure";
message: string;
}
| {
kind: "plan-resolution-failure";
scope: NativeServiceDoctorScope;
failures: readonly NativeServicePlanFailure[];
}
| {
kind: "plan-validation";
scope: NativeServiceDoctorScope;
plan: NativeServicePlan;
validation: { ok: true } | { ok: false; failures: readonly NativeServicePlanValidationFailure[] };
};
export interface NativeServiceDoctorReport {
ok: boolean;
failureKind: "none" | "requirements" | "infrastructure" | "inspection";
lines: readonly string[];
plan: NativeServicePlan | null;
adviceShell: NativeServiceShell | null;
pathAdviceRecommended: boolean;
failedPrerequisites: readonly NativeServicePrerequisite[];
}
interface ParsedServiceDefinition {
id: NativeServiceId;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
workingDirectory: string | null;
shellCommand: string;
}
export function inferInstalledNativeServiceMode(serviceIds: ReadonlySet<NativeServiceId>): InstalledNativeServiceMode {
if (serviceIds.size === 0) return "none";
const hasProductionWeb = serviceIds.has("web");
const hasDevelopmentUi = serviceIds.has("uiDev");
if (hasProductionWeb && !hasDevelopmentUi) return "production";
if (hasDevelopmentUi && !hasProductionWeb) return "development";
return "ambiguous";
}
export function inspectInstalledProductionServiceContext(
backend: NativeServiceBackend,
definitions: readonly InstalledNativeServiceDefinition[],
): InstalledNativeServiceInspection<InstalledNativeServiceContext> {
const parsed = parseConsistentDefinitions(backend, definitions);
if (!parsed.ok) return parsed;
const withWorkingDirectory = parsed.value.find((definition) => definition.workingDirectory !== null);
if (withWorkingDirectory !== undefined) {
return {
ok: false,
message: `Installed production service ${withWorkingDirectory.id} unexpectedly has working directory ${withWorkingDirectory.workingDirectory ?? ""}.`,
};
}
return {
ok: true,
value: {
shell: parsed.value[0]?.shell ?? impossibleMissingDefinition(),
environment: parsed.value[0]?.environment ?? impossibleMissingDefinition(),
},
};
}
export function inspectInstalledDevelopmentServiceInput(
backend: NativeServiceBackend,
definitions: readonly InstalledNativeServiceDefinition[],
): InstalledNativeServiceInspection<DevelopmentNativeServicePlanInput> {
const parsed = parseConsistentDefinitions(backend, definitions);
if (!parsed.ok) return parsed;
const first = parsed.value[0] ?? impossibleMissingDefinition();
if (first.workingDirectory === null) {
return { ok: false, message: "Installed development services do not declare a working directory." };
}
const input: DevelopmentNativeServicePlanInput = {
backend,
shell: first.shell,
environment: first.environment,
workingDirectory: first.workingDirectory,
packageJsonPath: posixPath.join(first.workingDirectory, "package.json"),
};
const expectedPlan = createDevelopmentNativeServicePlan(input);
for (const definition of parsed.value) {
const expected = expectedPlan.services.find((service) => service.id === definition.id);
if (expected?.shellCommand !== definition.shellCommand) {
return {
ok: false,
message: `Installed ${definition.id} service command does not match the canonical development plan.`,
};
}
}
return { ok: true, value: input };
}
export async function runNativeServiceDoctor(
target: NativeServiceDoctorTarget,
dependencies: NativeServicePlanDependencies,
): Promise<NativeServiceDoctorResult> {
if (target.kind === "inspection-failure") return target;
const scope: NativeServiceDoctorScope = target.kind === "installed-development"
? { kind: target.kind, reason: null, shell: target.input.shell }
: { kind: target.kind, reason: target.reason, shell: target.input.shell };
let plan: NativeServicePlan;
if (target.kind === "installed-development") {
plan = createDevelopmentNativeServicePlan(target.input);
} else {
const resolution = await resolveProductionNativeServicePlan(target.input, dependencies);
if (!resolution.ok) {
return { kind: "plan-resolution-failure", scope, failures: resolution.failures };
}
plan = resolution.plan;
}
const validation = await validateNativeServicePlan(plan, dependencies.probe);
return { kind: "plan-validation", scope, plan, validation };
}
export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResult): NativeServiceDoctorReport {
if (result.kind === "inspection-failure") {
return {
ok: false,
failureKind: "inspection",
lines: [
`✗ Installed native-service plan could not be inspected: ${result.message}`,
" Run `pi-web install` or `pi-web install --dev` to replace mixed, partial, or outdated service definitions.",
],
plan: null,
adviceShell: null,
pathAdviceRecommended: false,
failedPrerequisites: [],
};
}
const lines = [scopeHeading(result.scope)];
if (result.kind === "plan-resolution-failure") {
let infrastructure = false;
for (const failure of result.failures) {
if (failure.kind === "probe-infrastructure") {
infrastructure = true;
lines.push(`✗ Native service probe infrastructure failure (${failure.reason}): ${failure.message}`);
} else if (failure.kind === "entrypoint-inspection-failure") {
infrastructure = true;
lines.push(`✗ Could not inspect bundled ${failure.serviceId} entrypoint ${failure.entrypointPath}: ${failure.message}`);
} else {
lines.push(`${failure.namedCommand} is unavailable to the native service manager, and bundled entrypoint ${failure.bundledEntrypointPath} is missing.`);
if (failure.namedCommandFailure !== null) lines.push(` ${failure.namedCommandFailure}`);
}
}
if (infrastructure) lines.push(" This infrastructure failure is not proof of a PATH mismatch.");
return {
ok: false,
failureKind: infrastructure ? "infrastructure" : "requirements",
lines,
plan: null,
adviceShell: result.scope.shell,
pathAdviceRecommended: !infrastructure
&& result.failures.some((failure) => failure.kind === "executable-unavailable"),
failedPrerequisites: [],
};
}
const configuredOverrides = result.plan.services.filter((service) => service.strategy.kind === "configured-override");
for (const service of configuredOverrides) {
lines.push(`! ${service.description} uses a configured command override; doctor does not execute arbitrary configured commands.`);
}
if (result.validation.ok) {
lines.push("✓ All verifiable native-service plan requirements are satisfied in the service-manager context.");
return {
ok: true,
failureKind: "none",
lines,
plan: result.plan,
adviceShell: result.plan.shell,
pathAdviceRecommended: false,
failedPrerequisites: [],
};
}
const failedPrerequisites: NativeServicePrerequisite[] = [];
let infrastructure = false;
for (const failure of result.validation.failures) {
if (failure.kind === "probe-infrastructure") {
infrastructure = true;
lines.push(`✗ Native service probe infrastructure failure (${failure.reason}): ${failure.message}`);
} else {
failedPrerequisites.push(failure.prerequisite);
lines.push(`✗ Native service requirement failed: ${failure.prerequisite.description}`);
if (failure.detail !== null && failure.detail !== failure.prerequisite.description) lines.push(` ${failure.detail}`);
}
}
if (infrastructure) lines.push(" This infrastructure failure is not proof of a PATH mismatch.");
return {
ok: false,
failureKind: infrastructure ? "infrastructure" : "requirements",
lines,
plan: result.plan,
adviceShell: result.plan.shell,
pathAdviceRecommended: !infrastructure
&& failedPrerequisites.some(nativeServicePrerequisiteNeedsPathAdvice),
failedPrerequisites,
};
}
function scopeHeading(scope: NativeServiceDoctorScope): string {
if (scope.kind === "installed-development") return "Installed development native-service plan:";
return `Prospective production native-service plan (${scope.reason ?? "installed strategy is unknown"}):`;
}
function parseConsistentDefinitions(
backend: NativeServiceBackend,
definitions: readonly InstalledNativeServiceDefinition[],
): InstalledNativeServiceInspection<readonly ParsedServiceDefinition[]> {
if (definitions.length === 0) return { ok: false, message: "No installed service definitions were provided." };
const parsed: ParsedServiceDefinition[] = [];
for (const definition of definitions) {
const result = backend.kind === "systemd"
? parseSystemdDefinition(definition)
: parseLaunchdDefinition(definition);
if (!result.ok) return result;
parsed.push(result.value);
}
const first = parsed[0] ?? impossibleMissingDefinition();
for (const definition of parsed.slice(1)) {
if (definition.shell.executable !== first.shell.executable) {
return { ok: false, message: "Installed service definitions use different login shells." };
}
if (!recordsEqual(definition.environment, first.environment)) {
return { ok: false, message: "Installed service definitions use different environments." };
}
if (definition.workingDirectory !== first.workingDirectory) {
return { ok: false, message: "Installed service definitions use different working directories." };
}
}
return { ok: true, value: parsed };
}
interface ParsedSystemdDirective {
name: string;
value: string;
}
function systemdServiceDirectives(contents: string): ParsedSystemdDirective[] | undefined {
const allowed = new Set(["Type", "WorkingDirectory", "Environment", "ExecStart", "Restart", "RestartSec"]);
const directives: ParsedSystemdDirective[] = [];
let inServiceSection = false;
let foundServiceSection = false;
for (const line of contents.split(/\r?\n/u)) {
const trimmed = line.trim();
if (/^\[[^\]]+\]$/u.test(trimmed)) {
inServiceSection = trimmed === "[Service]";
foundServiceSection ||= inServiceSection;
continue;
}
if (!inServiceSection || trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
const match = /^\s*([A-Za-z][A-Za-z0-9]*)=(.*)$/u.exec(line);
const name = match?.[1];
const value = match?.[2];
if (name === undefined || value === undefined || !allowed.has(name)) return undefined;
directives.push({ name, value });
}
return foundServiceSection ? directives : undefined;
}
function parseSystemdDefinition(
definition: InstalledNativeServiceDefinition,
): InstalledNativeServiceInspection<ParsedServiceDefinition> {
const directives = systemdServiceDirectives(definition.contents);
if (directives === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has unrecognized service directives.` };
}
const execStarts = directives.filter((directive) => directive.name === "ExecStart");
const execStart = execStarts.length === 1
? /^(?:\/usr\/bin\/env )?(.+?) -lc (.+)$/u.exec(execStarts[0]?.value ?? "")
: null;
if (execStart?.[1] === undefined || execStart[2] === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit must have exactly one recognized ExecStart.` };
}
const shellExecutable = parseSystemdExecArgument(execStart[1]);
if (shellExecutable === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized login shell argument.` };
}
const shell = installedShell(shellExecutable);
if (!shell.ok) return shell;
const shellCommand = parseSystemdShellCommand(shell.value.name, execStart[2]);
if (shellCommand === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized shell command.` };
}
const environment: Record<string, string> = {};
for (const directive of directives.filter((item) => item.name === "Environment")) {
const rawValue = directive.value;
if (!/^"(?:\\.|[^"])*"$/u.test(rawValue)) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized environment entry.` };
}
const assignment = parseSystemdDirectiveValue(rawValue);
const separator = assignment?.indexOf("=") ?? -1;
const key = assignment?.slice(0, separator) ?? "";
if (separator <= 0 || Object.hasOwn(environment, key)) {
return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed environment entry.` };
}
environment[key] = assignment?.slice(separator + 1) ?? "";
}
const workingDirectories = directives.filter((directive) => directive.name === "WorkingDirectory");
if (workingDirectories.length > 1) {
return { ok: false, message: `Installed ${definition.id} systemd unit has duplicate working directories.` };
}
const rawWorkingDirectory = workingDirectories[0]?.value;
if (rawWorkingDirectory?.startsWith('"') === true || rawWorkingDirectory?.startsWith("'") === true) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an invalid quoted working directory.` };
}
const workingDirectory = rawWorkingDirectory === undefined
? null
: parseSystemdDirectiveValue(rawWorkingDirectory);
if (workingDirectories.length === 1 && workingDirectory === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed working directory.` };
}
return {
ok: true,
value: { id: definition.id, shell: shell.value, environment, workingDirectory: workingDirectory ?? null, shellCommand },
};
}
function parseLaunchdDefinition(
definition: InstalledNativeServiceDefinition,
): InstalledNativeServiceInspection<ParsedServiceDefinition> {
const argumentsMatches = [...definition.contents.matchAll(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/gu)];
const arguments_ = argumentsMatches.length === 1
? parseXmlStringSequence(argumentsMatches[0]?.[1] ?? "")
: undefined;
if (arguments_?.length !== 4 || arguments_[0] !== "/usr/bin/env" || arguments_[2] !== "-lc") {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has unrecognized ProgramArguments.` };
}
const shell = installedShell(arguments_[1] ?? "");
if (!shell.ok) return shell;
const environmentMatches = [...definition.contents.matchAll(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/gu)];
const environmentKeyCount = [...definition.contents.matchAll(/<key>EnvironmentVariables<\/key>/gu)].length;
if (environmentMatches.length > 1 || environmentKeyCount !== environmentMatches.length) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` };
}
const environment = environmentMatches.length === 0
? {}
: parseXmlStringDictionary(environmentMatches[0]?.[1] ?? "");
if (environment === undefined) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` };
}
const contentsWithoutEnvironment = environmentMatches[0]?.[0] === undefined
? definition.contents
: definition.contents.replace(environmentMatches[0][0], "");
const workingDirectoryMatches = [...contentsWithoutEnvironment.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/gu)];
const workingDirectoryKeyCount = [...contentsWithoutEnvironment.matchAll(/<key>WorkingDirectory<\/key>/gu)].length;
if (workingDirectoryMatches.length > 1 || workingDirectoryKeyCount !== workingDirectoryMatches.length) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` };
}
const workingDirectory = workingDirectoryMatches[0]?.[1] === undefined
? null
: xmlUnescapeStrict(workingDirectoryMatches[0][1]);
if (workingDirectoryMatches.length === 1 && workingDirectory === undefined) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` };
}
return {
ok: true,
value: {
id: definition.id,
shell: shell.value,
environment,
workingDirectory: workingDirectory ?? null,
shellCommand: arguments_[3] ?? "",
},
};
}
function installedShell(executable: string): InstalledNativeServiceInspection<NativeServiceShell> {
const name = posixPath.basename(executable).replace(/^-/, "");
if (name !== "bash" && name !== "zsh" && name !== "fish") {
return { ok: false, message: `Installed service definition uses unsupported login shell ${executable}.` };
}
return {
ok: true,
value: { name, executable, source: "detected", detectedExecutable: executable },
};
}
function parseSystemdExecArgument(value: string): string | undefined {
const decoded = parseSystemdEscapedValue(value);
return decoded === undefined ? undefined : decodeSystemdSubstitutions(decoded, true);
}
function parseSystemdDirectiveValue(value: string): string | undefined {
const decoded = parseSystemdEscapedValue(value);
return decoded === undefined ? undefined : decodeSystemdSubstitutions(decoded, false);
}
function parseSystemdEscapedValue(value: string): string | undefined {
const quoted = value.startsWith('"') || value.endsWith('"');
if (quoted && (!value.startsWith('"') || !value.endsWith('"'))) return undefined;
return systemdUnescape(quoted ? value.slice(1, -1) : value);
}
function systemdUnescape(value: string): string | undefined {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== "\\") {
result += character ?? "";
continue;
}
const escape = value[index + 1];
if (escape === undefined) return undefined;
const simpleEscapes: Readonly<Record<string, string>> = {
"\\": "\\",
'"': '"',
"'": "'",
a: "\u0007",
b: "\b",
e: "\u001b",
f: "\f",
n: "\n",
r: "\r",
s: " ",
t: "\t",
v: "\v",
};
const simple = simpleEscapes[escape];
if (simple !== undefined) {
result += simple;
index += 1;
continue;
}
const length = escape === "x" ? 2 : escape === "u" ? 4 : escape === "U" ? 8 : 0;
if (length === 0) return undefined;
const encoded = value.slice(index + 2, index + 2 + length);
if (encoded.length !== length || !new RegExp(`^[0-9a-fA-F]{${String(length)}}$`, "u").test(encoded)) return undefined;
const codePoint = Number.parseInt(encoded, 16);
if (codePoint === 0 || codePoint > 0x10ffff) return undefined;
result += String.fromCodePoint(codePoint);
index += length + 1;
}
return result;
}
function decodeSystemdSubstitutions(value: string, decodeDollars: boolean): string | undefined {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== "%" && !(decodeDollars && character === "$")) {
result += character ?? "";
continue;
}
if (value[index + 1] !== character) return undefined;
result += character;
index += 1;
}
return result;
}
function parseSystemdShellCommand(shell: NativeServiceShell["name"], value: string): string | undefined {
if (value.startsWith('"') || value.endsWith('"')) return parseSystemdExecArgument(value);
if (!value.startsWith("'") || !value.endsWith("'")) return undefined;
const inner = value.slice(1, -1);
const unquoted = shell === "fish" ? fishSingleQuoteUnescape(inner) : inner.replaceAll("'\\''", "'");
return decodeSystemdSubstitutions(unquoted, true);
}
function fishSingleQuoteUnescape(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === "\\" && index + 1 < value.length) {
result += value[index + 1] ?? "";
index += 1;
} else {
result += character ?? "";
}
}
return result;
}
function parseXmlStringSequence(contents: string): string[] | undefined {
const values: string[] = [];
let cursor = 0;
for (const match of contents.matchAll(/<string>([\s\S]*?)<\/string>/gu)) {
if (contents.slice(cursor, match.index).trim() !== "") return undefined;
const value = xmlUnescapeStrict(match[1] ?? "");
if (value === undefined) return undefined;
values.push(value);
cursor = match.index + match[0].length;
}
return contents.slice(cursor).trim() === "" ? values : undefined;
}
function parseXmlStringDictionary(contents: string): Record<string, string> | undefined {
const values: Record<string, string> = {};
let cursor = 0;
for (const match of contents.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)) {
if (contents.slice(cursor, match.index).trim() !== "") return undefined;
const key = xmlUnescapeStrict(match[1] ?? "");
const value = xmlUnescapeStrict(match[2] ?? "");
if (key === undefined || value === undefined || Object.hasOwn(values, key)) return undefined;
values[key] = value;
cursor = match.index + match[0].length;
}
return contents.slice(cursor).trim() === "" ? values : undefined;
}
function xmlUnescapeStrict(value: string): string | undefined {
if (/[<>]/u.test(value) || /&(?!(?:apos|quot|gt|lt|amp);)/u.test(value)) return undefined;
return value
.replaceAll("&apos;", "'")
.replaceAll("&quot;", '"')
.replaceAll("&gt;", ">")
.replaceAll("&lt;", "<")
.replaceAll("&amp;", "&");
}
function recordsEqual(left: Readonly<Record<string, string>>, right: Readonly<Record<string, string>>): boolean {
const leftEntries = Object.entries(left);
return leftEntries.length === Object.keys(right).length
&& leftEntries.every(([key, value]) => right[key] === value);
}
function impossibleMissingDefinition(): never {
throw new Error("Expected at least one installed native service definition");
}
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from "vitest";
import {
installNativeServiceCandidate,
nativeServiceInstallFailureNeedsPathAdvice,
} from "./serviceInstall.js";
import type {
NativeServiceAuthoritativeProbe,
NativeServicePlan,
NativeServiceProbeRequest,
ProductionNativeServicePlanInput,
} from "./servicePlan.js";
const productionInput: ProductionNativeServicePlanInput = {
backend: { kind: "systemd", label: "systemd user services" },
shell: {
name: "bash",
executable: "/bin/bash",
source: "detected",
detectedExecutable: "/bin/bash",
},
environment: { PI_WEB_CONFIG: "/home/user/config.json" },
executables: {
sessiond: {
configuredCommand: undefined,
namedCommand: "pi-web-sessiond",
bundledEntrypointPath: "/package/sessiond.js",
},
web: {
configuredCommand: undefined,
namedCommand: "pi-web-server",
bundledEntrypointPath: "/package/server.js",
},
},
};
function successfulProbe(events: string[]): NativeServiceAuthoritativeProbe {
return {
run: (request) => {
events.push(`probe:${request.purpose}`);
return Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status: "satisfied" as const,
detail: null,
})),
});
},
};
}
describe("native service install orchestration", () => {
it("resolves and validates the complete plan before writing config or replacing services", async () => {
const events: string[] = [];
const writeInitialConfig = vi.fn(() => { events.push("write-config"); return Promise.resolve(); });
const replaceServices = vi.fn((plan: NativeServicePlan) => {
events.push(`replace:${plan.mode}`);
return Promise.resolve();
});
const result = await installNativeServiceCandidate(
{ mode: "production", input: productionInput },
{
probe: successfulProbe(events),
fileExists: () => false,
writeInitialConfig,
replaceServices,
},
);
expect(result.ok).toBe(true);
expect(events).toEqual([
"probe:executable-selection",
"probe:plan-validation",
"write-config",
"replace:production",
]);
expect(writeInitialConfig).toHaveBeenCalledOnce();
expect(replaceServices).toHaveBeenCalledWith(expect.objectContaining({ mode: "production" }));
});
it("validates manager and shell readiness without executing configured overrides", async () => {
const writeInitialConfig = vi.fn<() => Promise<void>>(() => Promise.resolve());
const replaceServices = vi.fn<() => Promise<void>>(() => Promise.resolve());
const requests: NativeServiceProbeRequest[] = [];
const configuredInput: ProductionNativeServicePlanInput = {
...productionInput,
executables: {
sessiond: { ...productionInput.executables.sessiond, configuredCommand: "custom-sessiond --flag" },
web: { ...productionInput.executables.web, configuredCommand: "custom-web --flag" },
},
};
const result = await installNativeServiceCandidate(
{ mode: "production", input: configuredInput },
{
probe: {
run: (request) => {
requests.push(request);
return Promise.resolve({ kind: "infrastructure-failure", reason: "manager", message: "manager unavailable" });
},
},
fileExists: () => false,
writeInitialConfig,
replaceServices,
},
);
expect(result).toMatchObject({ ok: false, failure: { kind: "plan-validation" } });
if (result.ok) throw new Error("Expected manager validation failure");
expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(false);
expect(requests).toEqual([expect.objectContaining({ purpose: "plan-validation", prerequisites: [] })]);
expect(writeInitialConfig).not.toHaveBeenCalled();
expect(replaceServices).not.toHaveBeenCalled();
});
it("does not make durable changes when exact plan requirements are unsatisfied", async () => {
const writeInitialConfig = vi.fn<() => Promise<void>>(() => Promise.resolve());
const replaceServices = vi.fn<() => Promise<void>>(() => Promise.resolve());
const probe: NativeServiceAuthoritativeProbe = {
run: (request: NativeServiceProbeRequest) => Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status: request.purpose === "plan-validation" ? "unsatisfied" : "satisfied",
detail: "not visible in the service manager environment",
})),
}),
};
const result = await installNativeServiceCandidate(
{ mode: "production", input: productionInput },
{ probe, fileExists: () => false, writeInitialConfig, replaceServices },
);
expect(result).toMatchObject({ ok: false, failure: { kind: "plan-validation" } });
if (result.ok || result.failure.kind !== "plan-validation") throw new Error("Expected validation failure");
expect(result.failure.failures).not.toHaveLength(0);
expect(result.failure.failures.every((failure) => failure.kind === "prerequisite-unsatisfied")).toBe(true);
expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(true);
expect(writeInitialConfig).not.toHaveBeenCalled();
expect(replaceServices).not.toHaveBeenCalled();
});
it("does not mislabel probe infrastructure failures or write anything", async () => {
const writeInitialConfig = vi.fn<() => Promise<void>>(() => Promise.resolve());
const replaceServices = vi.fn<() => Promise<void>>(() => Promise.resolve());
const result = await installNativeServiceCandidate(
{ mode: "production", input: productionInput },
{
probe: {
run: () => Promise.resolve({
kind: "infrastructure-failure",
reason: "timeout",
message: "service manager probe timed out",
}),
},
fileExists: () => true,
writeInitialConfig,
replaceServices,
},
);
expect(result).toEqual({
ok: false,
failure: {
kind: "plan-resolution",
failures: [{
kind: "probe-infrastructure",
serviceIds: ["sessiond", "web"],
reason: "timeout",
message: "service manager probe timed out",
}],
},
});
if (result.ok) throw new Error("Expected infrastructure failure");
expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(false);
expect(writeInitialConfig).not.toHaveBeenCalled();
expect(replaceServices).not.toHaveBeenCalled();
});
});
+71
View File
@@ -0,0 +1,71 @@
import {
createDevelopmentNativeServicePlan,
nativeServicePrerequisiteNeedsPathAdvice,
resolveProductionNativeServicePlan,
validateNativeServicePlan,
type DevelopmentNativeServicePlanInput,
type NativeServiceAuthoritativeProbe,
type NativeServicePlan,
type NativeServicePlanDependencies,
type NativeServicePlanFailure,
type NativeServicePlanValidationFailure,
type ProductionNativeServicePlanInput,
} from "./servicePlan.js";
export type NativeServiceInstallCandidate =
| { mode: "production"; input: ProductionNativeServicePlanInput }
| { mode: "development"; input: DevelopmentNativeServicePlanInput };
export interface NativeServiceInstallDependencies extends NativeServicePlanDependencies {
probe: NativeServiceAuthoritativeProbe;
writeInitialConfig(): Promise<void>;
replaceServices(plan: NativeServicePlan): Promise<void>;
}
export type NativeServiceInstallFailure =
| { kind: "plan-resolution"; failures: readonly NativeServicePlanFailure[] }
| { kind: "plan-validation"; failures: readonly NativeServicePlanValidationFailure[] };
export type NativeServiceInstallResult =
| { ok: true; plan: NativeServicePlan }
| { ok: false; failure: NativeServiceInstallFailure };
export function nativeServiceInstallFailureNeedsPathAdvice(failure: NativeServiceInstallFailure): boolean {
if (failure.kind === "plan-resolution") {
return failure.failures.every((item) => item.kind === "executable-unavailable")
&& failure.failures.length > 0;
}
return failure.failures.some((item) =>
item.kind === "prerequisite-unsatisfied"
&& nativeServicePrerequisiteNeedsPathAdvice(item.prerequisite));
}
/**
* Keeps preflight effects ahead of durable install effects. The authoritative
* probes may create bounded temporary artifacts, but they must clean those up
* before this function writes config or replaces existing services.
*/
export async function installNativeServiceCandidate(
candidate: NativeServiceInstallCandidate,
dependencies: NativeServiceInstallDependencies,
): Promise<NativeServiceInstallResult> {
let plan: NativeServicePlan;
if (candidate.mode === "production") {
const resolution = await resolveProductionNativeServicePlan(candidate.input, dependencies);
if (!resolution.ok) {
return { ok: false, failure: { kind: "plan-resolution", failures: resolution.failures } };
}
plan = resolution.plan;
} else {
plan = createDevelopmentNativeServicePlan(candidate.input);
}
const validation = await validateNativeServicePlan(plan, dependencies.probe);
if (!validation.ok) {
return { ok: false, failure: { kind: "plan-validation", failures: validation.failures } };
}
await dependencies.writeInitialConfig();
await dependencies.replaceServices(plan);
return { ok: true, plan };
}
+373
View File
@@ -0,0 +1,373 @@
import { describe, expect, it, vi } from "vitest";
import {
createDevelopmentNativeServicePlan,
planValidationProbeRequests,
resolveProductionNativeServicePlan,
type NativeServiceAuthoritativeProbe,
type NativeServiceProbeRequest,
type NativeServiceProbeResult,
type ProductionNativeServicePlanInput,
} from "./servicePlan.js";
const backend = { kind: "systemd", label: "systemd user services" } as const;
const shell = {
name: "zsh",
executable: "/bin/zsh",
source: "detected",
detectedExecutable: "/bin/zsh",
} as const;
function productionInput(): ProductionNativeServicePlanInput {
return {
backend,
shell,
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
executables: {
sessiond: {
configuredCommand: undefined,
namedCommand: "pi-web-sessiond",
bundledEntrypointPath: "/package/dist/server/sessiond.js",
},
web: {
configuredCommand: undefined,
namedCommand: "pi-web-server",
bundledEntrypointPath: "/package/dist/server/index.js",
},
},
};
}
function completedProbe(status: "satisfied" | "unsatisfied", detail: string | null = null): NativeServiceAuthoritativeProbe {
return {
run: (request) => Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({ prerequisiteId: prerequisite.id, status, detail })),
}),
};
}
describe("production native service planning", () => {
it("selects named commands from one authoritative backend probe and carries their exact requirements", async () => {
const requests: NativeServiceProbeRequest[] = [];
const probe: NativeServiceAuthoritativeProbe = {
run: (request) => {
requests.push(request);
return Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status: "satisfied",
detail: "/usr/local/bin/example",
})),
});
},
};
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
probe,
fileExists: () => false,
});
expect(requests).toEqual([
{
purpose: "executable-selection",
backend,
shell,
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
workingDirectory: null,
prerequisites: [
expect.objectContaining({ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }),
expect.objectContaining({ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }),
],
},
]);
expect(resolution.ok).toBe(true);
if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures));
expect(resolution.plan).toMatchObject({
mode: "production",
backend,
shell,
services: [
{
id: "sessiond",
manager: { systemdName: "pi-web-sessiond.service", launchdLabel: "com.pi-web.sessiond" },
shellCommand: "exec pi-web-sessiond",
strategy: { kind: "named-command", command: "pi-web-sessiond", selectedBy: "authoritative-backend-probe" },
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
workingDirectory: null,
after: [],
wants: [],
prerequisites: [
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
],
},
{
id: "web",
shellCommand: "exec pi-web-server",
strategy: { kind: "named-command", command: "pi-web-server" },
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
{ id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 },
],
},
],
});
expect(planValidationProbeRequests(resolution.plan)).toMatchObject([
{
purpose: "plan-validation",
backend,
shell,
workingDirectory: null,
prerequisites: [
{ id: "sessiond.command.pi-web-sessiond" },
{ id: "sessiond.node" },
{ id: "web.command.pi-web-server" },
{ id: "web.node" },
],
},
]);
});
it("preserves configured overrides verbatim and never probes or executes them", async () => {
const input = productionInput();
input.executables.sessiond.configuredCommand = " /opt/pi web/run-sessiond --flag ";
input.executables.web.configuredCommand = "custom-web --serve";
const run = vi.fn<(request: NativeServiceProbeRequest) => Promise<NativeServiceProbeResult>>();
const fileExists = vi.fn<(path: string) => boolean>();
const resolution = await resolveProductionNativeServicePlan(input, { probe: { run }, fileExists });
expect(run).not.toHaveBeenCalled();
expect(fileExists).not.toHaveBeenCalled();
expect(resolution.ok).toBe(true);
if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures));
expect(resolution.plan.services).toMatchObject([
{
id: "sessiond",
shellCommand: "exec /opt/pi web/run-sessiond --flag ",
strategy: {
kind: "configured-override",
command: " /opt/pi web/run-sessiond --flag ",
verification: "unverified",
},
prerequisites: [],
},
{
id: "web",
shellCommand: "exec custom-web --serve",
strategy: { kind: "configured-override", command: "custom-web --serve", verification: "unverified" },
prerequisites: [],
},
]);
expect(planValidationProbeRequests(resolution.plan)).toEqual([{
purpose: "plan-validation",
backend,
shell,
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
workingDirectory: null,
prerequisites: [],
}]);
});
it("falls back per service to bundled entrypoints when named commands are unavailable", async () => {
const input = productionInput();
input.executables.sessiond.bundledEntrypointPath = "/package with space/sessiond's entry.js";
const fileExists = vi.fn<(path: string) => boolean>(() => true);
const resolution = await resolveProductionNativeServicePlan(input, {
probe: completedProbe("unsatisfied", "command not found"),
fileExists,
});
expect(fileExists).toHaveBeenCalledTimes(2);
expect(resolution.ok).toBe(true);
if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures));
expect(resolution.plan.services[0]).toMatchObject({
shellCommand: "exec node '/package with space/sessiond'\\''s entry.js'",
strategy: {
kind: "bundled-entrypoint",
command: "node",
namedCommand: "pi-web-sessiond",
namedCommandFailure: "command not found",
},
prerequisites: [
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" },
],
});
});
it("mixes configured, named, and bundled decisions without unrelated checks", async () => {
const input = productionInput();
input.executables.sessiond.configuredCommand = "custom-sessiond";
const requests: NativeServiceProbeRequest[] = [];
const resolution = await resolveProductionNativeServicePlan(input, {
probe: {
run: (request) => {
requests.push(request);
return Promise.resolve({
kind: "completed",
outcomes: [{ prerequisiteId: "web.command.pi-web-server", status: "unsatisfied", detail: null }],
});
},
},
fileExists: () => true,
});
expect(requests[0]?.prerequisites).toMatchObject([{ id: "web.command.pi-web-server", command: "pi-web-server" }]);
expect(resolution.ok).toBe(true);
if (resolution.ok) {
expect(resolution.plan.services.map((service) => service.strategy.kind)).toEqual(["configured-override", "bundled-entrypoint"]);
}
});
it("returns structured failures when neither production executable strategy is viable", async () => {
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
probe: completedProbe("unsatisfied", "not found in service PATH"),
fileExists: () => false,
});
expect(resolution).toEqual({
ok: false,
failures: [
{
kind: "executable-unavailable",
serviceId: "sessiond",
namedCommand: "pi-web-sessiond",
namedCommandFailure: "not found in service PATH",
bundledEntrypointPath: "/package/dist/server/sessiond.js",
},
{
kind: "executable-unavailable",
serviceId: "web",
namedCommand: "pi-web-server",
namedCommandFailure: "not found in service PATH",
bundledEntrypointPath: "/package/dist/server/index.js",
},
],
});
});
it("does not reinterpret probe infrastructure failures as missing commands", async () => {
const fileExists = vi.fn<(path: string) => boolean>(() => true);
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
probe: {
run: () => Promise.resolve({ kind: "infrastructure-failure", reason: "cleanup", message: "launchd probe cleanup failed" }),
},
fileExists,
});
expect(fileExists).not.toHaveBeenCalled();
expect(resolution).toEqual({
ok: false,
failures: [{
kind: "probe-infrastructure",
serviceIds: ["sessiond", "web"],
reason: "cleanup",
message: "launchd probe cleanup failed",
}],
});
});
it("treats thrown and malformed probe results as infrastructure failures", async () => {
const thrown = await resolveProductionNativeServicePlan(productionInput(), {
probe: { run: () => Promise.reject(new Error("systemd-run failed")) },
fileExists: () => true,
});
expect(thrown).toMatchObject({
ok: false,
failures: [{ kind: "probe-infrastructure", reason: "manager", message: "systemd-run failed" }],
});
const malformed = await resolveProductionNativeServicePlan(productionInput(), {
probe: {
run: () => Promise.resolve({
kind: "completed",
outcomes: [{ prerequisiteId: "sessiond.command.pi-web-sessiond", status: "satisfied", detail: null }],
}),
},
fileExists: () => true,
});
expect(malformed).toMatchObject({
ok: false,
failures: [{ kind: "probe-infrastructure", reason: "malformed-output", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }],
});
});
});
describe("development native service planning", () => {
it("plans only the exact checkout commands and prerequisites", () => {
const plan = createDevelopmentNativeServicePlan({
backend: { kind: "launchd", label: "LaunchAgents" },
shell: { name: "fish", executable: "/opt/homebrew/bin/fish", source: "detected", detectedExecutable: "/opt/homebrew/bin/fish" },
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
workingDirectory: "/checkout with space",
packageJsonPath: "/checkout with space/package.json",
});
expect(plan).toMatchObject({
mode: "development",
backend: { kind: "launchd" },
shell: { name: "fish", executable: "/opt/homebrew/bin/fish" },
services: [
{
id: "sessiond",
shellCommand: "exec npm run start:sessiond",
strategy: { kind: "development-npm-script", script: "start:sessiond" },
restart: "never",
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
workingDirectory: "/checkout with space",
prerequisites: [
{ id: "sessiond.node", kind: "node-version", minimumMajor: 22 },
{ id: "sessiond.command.npm", kind: "command-available", command: "npm" },
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
],
},
{
id: "uiDev",
shellCommand: "exec /usr/bin/env bash -c 'trap \"kill 0\" EXIT; npm run dev:web & npm run dev:client & wait'",
strategy: { kind: "development-npm-script-group", scripts: ["dev:web", "dev:client"], interpreter: "bash" },
restart: "never",
workingDirectory: "/checkout with space",
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
{ id: "uiDev.node", kind: "node-version", minimumMajor: 22 },
{ id: "uiDev.command.npm", kind: "command-available", command: "npm" },
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
],
},
],
});
const serviceCommandRequirements = plan.services.flatMap((service) => service.prerequisites)
.filter((prerequisite) => prerequisite.kind === "command-available")
.map((prerequisite) => prerequisite.command);
expect(serviceCommandRequirements).toEqual(["npm", "npm", "bash"]);
expect(serviceCommandRequirements).not.toContain("pi-web-server");
expect(serviceCommandRequirements).not.toContain("pi-web-sessiond");
expect(planValidationProbeRequests(plan)).toMatchObject([
{
backend: { kind: "launchd" },
workingDirectory: "/checkout with space",
prerequisites: [
{ id: "sessiond.node" },
{ id: "sessiond.command.npm" },
{ id: "sessiond.package-scripts" },
{ id: "uiDev.node" },
{ id: "uiDev.command.npm" },
{ id: "uiDev.command.bash" },
{ id: "uiDev.package-scripts" },
],
},
]);
});
});
+623
View File
@@ -0,0 +1,623 @@
export type NativeServiceBackendKind = "systemd" | "launchd";
export type NativeServiceMode = "production" | "development";
export type NativeServiceId = "sessiond" | "web" | "uiDev";
export type ProductionNativeServiceId = Extract<NativeServiceId, "sessiond" | "web">;
export type NativeServiceShellName = "bash" | "zsh" | "fish";
export type NativeServiceRestartPolicy = "on-failure" | "never";
export type NativeServiceProbeInfrastructureReason = "manager" | "timeout" | "malformed-output" | "cleanup";
export interface NativeServiceBackend {
kind: NativeServiceBackendKind;
label: string;
}
export interface NativeServiceShell {
name: NativeServiceShellName;
executable: string;
source: "detected" | "fallback";
detectedExecutable: string | null;
}
export interface NativeServiceManagerRef {
systemdName: string;
launchdLabel: string;
launchdPlistName: string;
logName: string;
}
export type NativeServiceCommandStrategy =
| {
kind: "configured-override";
command: string;
verification: "unverified";
}
| {
kind: "named-command";
command: string;
selectedBy: "authoritative-backend-probe";
}
| {
kind: "bundled-entrypoint";
command: "node";
entrypointPath: string;
namedCommand: string;
namedCommandFailure: string | null;
}
| {
kind: "development-npm-script";
script: string;
}
| {
kind: "development-npm-script-group";
scripts: readonly string[];
interpreter: "bash";
};
export type NativeServicePrerequisite =
| {
id: string;
kind: "command-available";
command: string;
description: string;
}
| {
id: string;
kind: "node-version";
command: "node";
minimumMajor: number;
description: string;
}
| {
id: string;
kind: "readable-file";
path: string;
description: string;
}
| {
id: string;
kind: "package-scripts";
packageJsonPath: string;
scripts: readonly string[];
description: string;
};
export interface NativeServicePlanService {
id: NativeServiceId;
manager: NativeServiceManagerRef;
description: string;
shellCommand: string;
strategy: NativeServiceCommandStrategy;
restart: NativeServiceRestartPolicy;
environment: Readonly<Record<string, string>>;
workingDirectory: string | null;
after: readonly NativeServiceId[];
wants: readonly NativeServiceId[];
prerequisites: readonly NativeServicePrerequisite[];
}
export interface NativeServicePlan {
mode: NativeServiceMode;
backend: NativeServiceBackend;
shell: NativeServiceShell;
services: readonly NativeServicePlanService[];
}
export interface NativeServiceProbeRequest {
purpose: "executable-selection" | "plan-validation";
backend: NativeServiceBackend;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
workingDirectory: string | null;
prerequisites: readonly NativeServicePrerequisite[];
}
export interface NativeServicePrerequisiteOutcome {
prerequisiteId: string;
status: "satisfied" | "unsatisfied";
detail: string | null;
}
export type NativeServiceProbeResult =
| {
kind: "completed";
outcomes: readonly NativeServicePrerequisiteOutcome[];
}
| {
kind: "infrastructure-failure";
reason: NativeServiceProbeInfrastructureReason;
message: string;
};
/**
* Runs requirements in the real native service-manager context represented by
* the request. Implementations must not treat the caller shell or a simulated
* `env -i` environment as authoritative. Timeouts, manager failures, malformed
* output, and cleanup failures are infrastructure failures; a missing command
* is a completed probe with an unsatisfied outcome.
*/
export interface NativeServiceAuthoritativeProbe {
run(request: NativeServiceProbeRequest): Promise<NativeServiceProbeResult>;
}
export interface ProductionNativeServiceExecutableInput {
configuredCommand: string | undefined;
namedCommand: string;
bundledEntrypointPath: string;
}
export interface ProductionNativeServicePlanInput {
backend: NativeServiceBackend;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
executables: Readonly<Record<ProductionNativeServiceId, ProductionNativeServiceExecutableInput>>;
}
export interface DevelopmentNativeServicePlanInput {
backend: NativeServiceBackend;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
workingDirectory: string;
packageJsonPath: string;
}
export interface NativeServicePlanDependencies {
probe: NativeServiceAuthoritativeProbe;
/** Returns true only when the path exists and is a regular file. */
fileExists(path: string): boolean;
}
export type NativeServicePlanFailure =
| {
kind: "probe-infrastructure";
serviceIds: readonly ProductionNativeServiceId[];
reason: NativeServiceProbeInfrastructureReason;
message: string;
}
| {
kind: "entrypoint-inspection-failure";
serviceId: ProductionNativeServiceId;
entrypointPath: string;
message: string;
}
| {
kind: "executable-unavailable";
serviceId: ProductionNativeServiceId;
namedCommand: string;
namedCommandFailure: string | null;
bundledEntrypointPath: string;
};
export type NativeServicePlanResolution =
| { ok: true; plan: NativeServicePlan }
| { ok: false; failures: readonly NativeServicePlanFailure[] };
export type NativeServicePlanValidationFailure =
| {
kind: "prerequisite-unsatisfied";
prerequisite: NativeServicePrerequisite;
detail: string | null;
}
| {
kind: "probe-infrastructure";
reason: NativeServiceProbeInfrastructureReason;
message: string;
};
export type NativeServicePlanValidation =
| { ok: true }
| { ok: false; failures: readonly NativeServicePlanValidationFailure[] };
export function nativeServicePrerequisiteNeedsPathAdvice(prerequisite: NativeServicePrerequisite): boolean {
return prerequisite.kind === "command-available" || prerequisite.kind === "node-version";
}
export const nativeServiceManagerRefs: Readonly<Record<NativeServiceId, NativeServiceManagerRef>> = {
sessiond: {
systemdName: "pi-web-sessiond.service",
launchdLabel: "com.pi-web.sessiond",
launchdPlistName: "com.pi-web.sessiond.plist",
logName: "sessiond.log",
},
web: {
systemdName: "pi-web.service",
launchdLabel: "com.pi-web.web",
launchdPlistName: "com.pi-web.web.plist",
logName: "web.log",
},
uiDev: {
systemdName: "pi-web-ui-dev.service",
launchdLabel: "com.pi-web.ui-dev",
launchdPlistName: "com.pi-web.ui-dev.plist",
logName: "ui-dev.log",
},
};
export const productionNativeServiceIds = ["sessiond", "web"] as const satisfies readonly ProductionNativeServiceId[];
export async function resolveProductionNativeServicePlan(
input: ProductionNativeServicePlanInput,
dependencies: NativeServicePlanDependencies,
): Promise<NativeServicePlanResolution> {
const configuredStrategies = new Map<ProductionNativeServiceId, NativeServiceCommandStrategy>();
const selectionRequirements: NativeServicePrerequisite[] = [];
const serviceIdsToProbe: ProductionNativeServiceId[] = [];
for (const serviceId of productionNativeServiceIds) {
const executable = input.executables[serviceId];
if (hasConfiguredCommand(executable.configuredCommand)) {
configuredStrategies.set(serviceId, {
kind: "configured-override",
command: executable.configuredCommand,
verification: "unverified",
});
continue;
}
serviceIdsToProbe.push(serviceId);
selectionRequirements.push(commandRequirement(serviceId, executable.namedCommand));
}
let outcomes = new Map<string, NativeServicePrerequisiteOutcome>();
if (selectionRequirements.length > 0) {
const probeResult = await runSelectionProbe(input, selectionRequirements, dependencies.probe);
if (probeResult.kind === "infrastructure-failure") {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, reason: probeResult.reason, message: probeResult.message }],
};
}
const parsedOutcomes = probeOutcomes(selectionRequirements, probeResult.outcomes);
if (parsedOutcomes.kind === "infrastructure-failure") {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, reason: parsedOutcomes.reason, message: parsedOutcomes.message }],
};
}
outcomes = parsedOutcomes.outcomes;
}
const strategies = new Map(configuredStrategies);
const failures: NativeServicePlanFailure[] = [];
for (const serviceId of serviceIdsToProbe) {
const executable = input.executables[serviceId];
const outcome = outcomes.get(commandRequirementId(serviceId, executable.namedCommand));
if (outcome?.status === "satisfied") {
strategies.set(serviceId, {
kind: "named-command",
command: executable.namedCommand,
selectedBy: "authoritative-backend-probe",
});
continue;
}
let entrypointExists: boolean;
try {
entrypointExists = dependencies.fileExists(executable.bundledEntrypointPath);
} catch (error: unknown) {
failures.push({
kind: "entrypoint-inspection-failure",
serviceId,
entrypointPath: executable.bundledEntrypointPath,
message: errorMessage(error),
});
continue;
}
if (entrypointExists) {
strategies.set(serviceId, {
kind: "bundled-entrypoint",
command: "node",
entrypointPath: executable.bundledEntrypointPath,
namedCommand: executable.namedCommand,
namedCommandFailure: outcome?.detail ?? null,
});
continue;
}
failures.push({
kind: "executable-unavailable",
serviceId,
namedCommand: executable.namedCommand,
namedCommandFailure: outcome?.detail ?? null,
bundledEntrypointPath: executable.bundledEntrypointPath,
});
}
if (failures.length > 0) return { ok: false, failures };
return {
ok: true,
plan: {
mode: "production",
backend: input.backend,
shell: input.shell,
services: productionNativeServiceIds.map((serviceId) => productionService(input, serviceId, requiredStrategy(strategies, serviceId))),
},
};
}
export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServicePlanInput): NativeServicePlan {
const environment = copyEnvironment(input.environment);
const sessiondScripts = ["start:sessiond"] as const;
const uiDevScripts = ["dev:web", "dev:client"] as const;
const uiDevCommand = 'trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait';
return {
mode: "development",
backend: input.backend,
shell: input.shell,
services: [
{
id: "sessiond",
manager: nativeServiceManagerRefs.sessiond,
description: "PI WEB session daemon (dev)",
shellCommand: "exec npm run start:sessiond",
strategy: { kind: "development-npm-script", script: "start:sessiond" },
restart: "never",
environment,
workingDirectory: input.workingDirectory,
after: [],
wants: [],
prerequisites: [
nodeRequirement("sessiond"),
commandRequirement("sessiond", "npm"),
packageScriptsRequirement("sessiond", input.packageJsonPath, sessiondScripts),
],
},
{
id: "uiDev",
manager: nativeServiceManagerRefs.uiDev,
description: "PI WEB UI dev server",
shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`,
strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" },
restart: "never",
environment,
workingDirectory: input.workingDirectory,
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
nodeRequirement("uiDev"),
commandRequirement("uiDev", "npm"),
commandRequirement("uiDev", "bash"),
packageScriptsRequirement("uiDev", input.packageJsonPath, uiDevScripts),
],
},
],
};
}
export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] {
const requests: (Omit<NativeServiceProbeRequest, "prerequisites"> & { prerequisites: NativeServicePrerequisite[] })[] = [];
for (const service of plan.services) {
const existing = requests.find((request) =>
request.workingDirectory === service.workingDirectory
&& environmentsEqual(request.environment, service.environment));
if (existing === undefined) {
requests.push({
purpose: "plan-validation",
backend: plan.backend,
shell: plan.shell,
environment: service.environment,
workingDirectory: service.workingDirectory,
prerequisites: [...service.prerequisites],
});
continue;
}
existing.prerequisites.push(...service.prerequisites);
}
return requests;
}
export async function validateNativeServicePlan(
plan: NativeServicePlan,
probe: NativeServiceAuthoritativeProbe,
): Promise<NativeServicePlanValidation> {
const failures: NativeServicePlanValidationFailure[] = [];
for (const request of planValidationProbeRequests(plan)) {
let result: NativeServiceProbeResult;
try {
result = await probe.run(request);
} catch (error: unknown) {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", reason: "manager", message: errorMessage(error) }],
};
}
if (result.kind === "infrastructure-failure") {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", reason: result.reason, message: result.message }],
};
}
const parsed = probeOutcomes(request.prerequisites, result.outcomes);
if (parsed.kind === "infrastructure-failure") {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", reason: parsed.reason, message: parsed.message }],
};
}
for (const prerequisite of request.prerequisites) {
const outcome = parsed.outcomes.get(prerequisite.id);
if (outcome?.status === "unsatisfied") {
failures.push({ kind: "prerequisite-unsatisfied", prerequisite, detail: outcome.detail });
}
}
}
return failures.length === 0 ? { ok: true } : { ok: false, failures };
}
function productionService(
input: ProductionNativeServicePlanInput,
serviceId: ProductionNativeServiceId,
strategy: NativeServiceCommandStrategy,
): NativeServicePlanService {
const isWeb = serviceId === "web";
return {
id: serviceId,
manager: nativeServiceManagerRefs[serviceId],
description: isWeb ? "PI WEB server" : "PI WEB session daemon",
shellCommand: `exec ${strategyCommand(input.shell, strategy)}`,
strategy,
restart: "on-failure",
environment: copyEnvironment(input.environment),
workingDirectory: null,
after: isWeb ? ["sessiond"] : [],
wants: isWeb ? ["sessiond"] : [],
prerequisites: strategyPrerequisites(serviceId, strategy),
};
}
function strategyCommand(shell: NativeServiceShell, strategy: NativeServiceCommandStrategy): string {
switch (strategy.kind) {
case "configured-override":
case "named-command":
return strategy.command;
case "bundled-entrypoint":
return `${strategy.command} ${shellSingleQuote(shell.name, strategy.entrypointPath)}`;
case "development-npm-script":
return `npm run ${strategy.script}`;
case "development-npm-script-group":
throw new Error("Development script groups define their complete service shell command");
}
}
function strategyPrerequisites(serviceId: ProductionNativeServiceId, strategy: NativeServiceCommandStrategy): readonly NativeServicePrerequisite[] {
switch (strategy.kind) {
case "configured-override":
return [];
case "named-command":
return [commandRequirement(serviceId, strategy.command), nodeRequirement(serviceId)];
case "bundled-entrypoint":
return [nodeRequirement(serviceId), readableFileRequirement(serviceId, strategy.entrypointPath)];
case "development-npm-script":
case "development-npm-script-group":
throw new Error(`Unexpected ${strategy.kind} strategy in a production plan`);
}
}
async function runSelectionProbe(
input: ProductionNativeServicePlanInput,
prerequisites: readonly NativeServicePrerequisite[],
probe: NativeServiceAuthoritativeProbe,
): Promise<NativeServiceProbeResult> {
try {
return await probe.run({
purpose: "executable-selection",
backend: input.backend,
shell: input.shell,
environment: copyEnvironment(input.environment),
workingDirectory: null,
prerequisites,
});
} catch (error: unknown) {
return { kind: "infrastructure-failure", reason: "manager", message: errorMessage(error) };
}
}
function probeOutcomes(
prerequisites: readonly NativeServicePrerequisite[],
outcomes: readonly NativeServicePrerequisiteOutcome[],
): { kind: "completed"; outcomes: Map<string, NativeServicePrerequisiteOutcome> } | { kind: "infrastructure-failure"; reason: "malformed-output"; message: string } {
const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id));
const byId = new Map<string, NativeServicePrerequisiteOutcome>();
for (const outcome of outcomes) {
if (!expectedIds.has(outcome.prerequisiteId)) {
return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` };
}
if (byId.has(outcome.prerequisiteId)) {
return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` };
}
byId.set(outcome.prerequisiteId, outcome);
}
const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id));
if (missing !== undefined) {
return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned no outcome for ${missing.id}.` };
}
return { kind: "completed", outcomes: byId };
}
function requiredStrategy(
strategies: ReadonlyMap<ProductionNativeServiceId, NativeServiceCommandStrategy>,
serviceId: ProductionNativeServiceId,
): NativeServiceCommandStrategy {
const strategy = strategies.get(serviceId);
if (strategy === undefined) throw new Error(`Missing executable strategy for ${serviceId}`);
return strategy;
}
function hasConfiguredCommand(command: string | undefined): command is string {
return command !== undefined && command.trim() !== "";
}
function commandRequirementId(serviceId: NativeServiceId, command: string): string {
return `${serviceId}.command.${command}`;
}
function commandRequirement(serviceId: NativeServiceId, command: string): NativeServicePrerequisite {
return {
id: commandRequirementId(serviceId, command),
kind: "command-available",
command,
description: `${command} resolves to an external executable for the service shell`,
};
}
function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite {
return {
id: `${serviceId}.node`,
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22 is available to the service shell",
};
}
function readableFileRequirement(serviceId: NativeServiceId, path: string): NativeServicePrerequisite {
return {
id: `${serviceId}.entrypoint`,
kind: "readable-file",
path,
description: `bundled entrypoint is a readable regular file: ${path}`,
};
}
function packageScriptsRequirement(
serviceId: NativeServiceId,
packageJsonPath: string,
scripts: readonly string[],
): NativeServicePrerequisite {
return {
id: `${serviceId}.package-scripts`,
kind: "package-scripts",
packageJsonPath,
scripts,
description: `package.json defines scripts: ${scripts.join(", ")}`,
};
}
function shellSingleQuote(shell: NativeServiceShellName, value: string): string {
if (shell === "fish") return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
return `'${value.replaceAll("'", "'\\''")}'`;
}
function copyEnvironment(environment: Readonly<Record<string, string>>): Readonly<Record<string, string>> {
return { ...environment };
}
function environmentsEqual(
left: Readonly<Record<string, string>>,
right: Readonly<Record<string, string>>,
): boolean {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
return leftEntries.length === rightEntries.length
&& leftEntries.every(([key, value]) => right[key] === value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it, vi } from "vitest";
import {
LaunchdNativeServiceProbe,
SpawnProbeCommandRunner,
SystemdNativeServiceProbe,
launchdProbePlist,
nativeServicePrerequisiteShellCheck,
systemdRunArguments,
type LaunchdProbeFileSystem,
type ProbeCommandResult,
type ProbeCommandRunner,
} from "./serviceProbe.js";
import type { NativeServiceProbeRequest } from "./servicePlan.js";
function request(kind: "systemd" | "launchd" = "systemd"): NativeServiceProbeRequest {
return {
purpose: "plan-validation",
backend: { kind, label: kind },
shell: {
name: "zsh",
executable: "/bin/zsh",
source: "detected",
detectedExecutable: "/bin/zsh",
},
environment: { PI_WEB_CONFIG: "/home/user/config with space.json" },
workingDirectory: "/checkout with space",
prerequisites: [{
id: "sessiond.command.npm",
kind: "command-available",
command: "npm",
description: "npm is available",
}],
};
}
function completed(status = 0, stdout = "", stderr = ""): ProbeCommandResult {
return { kind: "completed", status, stdout, stderr };
}
function marker(id: string, status: "satisfied" | "unsatisfied"): string {
return `PI_WEB_PROBE_fixed\t${Buffer.from(id).toString("base64")}\t${status}\n`;
}
function queuedRunner(results: ProbeCommandResult[]): ProbeCommandRunner & { calls: { command: string; args: readonly string[]; timeoutMs: number }[] } {
const calls: { command: string; args: readonly string[]; timeoutMs: number }[] = [];
return {
calls,
run: (command, args, timeoutMs) => {
calls.push({ command, args, timeoutMs });
const result = results.shift();
if (result === undefined) throw new Error(`Unexpected command: ${command} ${args.join(" ")}`);
return Promise.resolve(result);
},
};
}
describe("systemd authoritative native-service probe", () => {
it("runs the exact shell, environment, and cwd in a transient user service", async () => {
const runner = queuedRunner([completed(0, `login banner\n${marker("sessiond.command.npm", "satisfied")}`)]);
const probe = new SystemdNativeServiceProbe({
commandRunner: runner,
createUniqueId: () => "fixed",
commandTimeoutMs: 3210,
});
await expect(probe.run(request())).resolves.toEqual({
kind: "completed",
outcomes: [{ prerequisiteId: "sessiond.command.npm", status: "satisfied", detail: null }],
});
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0]).toMatchObject({ command: "systemd-run", timeoutMs: 3210 });
expect(runner.calls[0]?.args).toEqual([
"--user",
"--wait",
"--collect",
"--pipe",
"--quiet",
"--unit=pi-web-authoritative-probe-fixed.service",
"--property=RuntimeMaxSec=15s",
"--property=TimeoutStopSec=5s",
"--setenv=PI_WEB_CONFIG=/home/user/config with space.json",
"--working-directory=/checkout with space",
"/usr/bin/env",
"/bin/zsh",
"-lc",
expect.stringContaining("command -v 'npm'"),
]);
});
it("reports requirement failures as completed and malformed output as infrastructure", async () => {
const unsatisfiedRunner = queuedRunner([completed(0, marker("sessiond.command.npm", "unsatisfied"))]);
const dependencies = { commandRunner: unsatisfiedRunner, createUniqueId: () => "fixed", commandTimeoutMs: 100 };
await expect(new SystemdNativeServiceProbe(dependencies).run(request())).resolves.toEqual({
kind: "completed",
outcomes: [{
prerequisiteId: "sessiond.command.npm",
status: "unsatisfied",
detail: "npm did not resolve to an external executable in the native service environment.",
}],
});
const malformedRunner = queuedRunner([completed(0, "no marker here")]);
await expect(new SystemdNativeServiceProbe({ ...dependencies, commandRunner: malformedRunner }).run(request())).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "malformed-output",
});
});
it("bounds a hung unit, cleans it up, and reports the timeout", async () => {
const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" },
completed(0),
completed(0, "not-found\n"),
]);
const probe = new SystemdNativeServiceProbe({
commandRunner: runner,
createUniqueId: () => "fixed",
commandTimeoutMs: 100,
});
await expect(probe.run(request())).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "timeout",
});
expect(runner.calls.map(({ command }) => command)).toEqual(["systemd-run", "systemctl", "systemctl"]);
});
it("bounds a hung unit and distinguishes cleanup failure", async () => {
const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" },
completed(0),
completed(0, "loaded\n", "unit still loaded"),
]);
const probe = new SystemdNativeServiceProbe({
commandRunner: runner,
createUniqueId: () => "fixed",
commandTimeoutMs: 100,
});
const result = await probe.run(request());
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("unit still loaded");
expect(runner.calls.map(({ command, args }) => [command, ...args.slice(0, 3)])).toEqual([
["systemd-run", "--user", "--wait", "--collect"],
["systemctl", "--user", "stop", "pi-web-authoritative-probe-fixed.service"],
["systemctl", "--user", "show", "pi-web-authoritative-probe-fixed.service"],
]);
});
});
describe("spawn probe command runner", () => {
it("bounds captured command output", async () => {
const runner = new SpawnProbeCommandRunner();
const result = await runner.run(
process.execPath,
["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"],
5_000,
);
expect(result).toMatchObject({ kind: "output-limit" });
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
});
it("settles a timeout without waiting for inherited pipes to close", async () => {
const runner = new SpawnProbeCommandRunner();
const childScript = [
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'inherit'] });",
"child.unref();",
].join(" ");
const startedAt = performance.now();
await expect(runner.run(process.execPath, ["-e", childScript], 20)).resolves.toMatchObject({ kind: "timeout" });
expect(performance.now() - startedAt).toBeLessThan(500);
});
});
describe("launchd authoritative native-service probe", () => {
it("bootstraps a uniquely labelled one-shot agent in gui/<uid> and always cleans it up", async () => {
const runner = queuedRunner([completed(0), completed(0)]);
const fileSystem = launchdFileSystem({
"/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"),
});
let now = 0;
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 501,
createUniqueId: () => "fixed",
now: () => now,
sleep: (milliseconds) => { now += milliseconds; return Promise.resolve(); },
probeTimeoutMs: 500,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({ kind: "completed" });
expect(runner.calls.map(({ command, args }) => [command, ...args])).toEqual([
["launchctl", "bootstrap", "gui/501", "/tmp/probe/probe.plist"],
["launchctl", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"],
]);
expect(fileSystem.writeFile).toHaveBeenCalledWith(
"/tmp/probe/probe.plist",
expect.stringContaining("<string>/bin/zsh</string>"),
0o600,
);
expect(fileSystem.writeFile).toHaveBeenCalledWith(
"/tmp/probe/probe.plist",
expect.stringContaining("<key>WorkingDirectory</key>\n <string>/checkout with space</string>"),
0o600,
);
expect(fileSystem.writeFile).toHaveBeenCalledWith(
"/tmp/probe/probe.plist",
expect.stringContaining("/bin/mv &apos;/tmp/probe/result.pending&apos; &apos;/tmp/probe/result.log&apos;"),
0o600,
);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("times out deterministically, boots out the agent, and removes temporary files", async () => {
const runner = queuedRunner([completed(0), completed(0)]);
const fileSystem = launchdFileSystem({});
let now = 0;
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 502,
createUniqueId: () => "fixed",
now: () => now,
sleep: (milliseconds) => { now += milliseconds; return Promise.resolve(); },
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "timeout",
});
expect(runner.calls.at(-1)).toMatchObject({
command: "launchctl",
args: ["bootout", "gui/502/com.pi-web.authoritative-probe.502.fixed"],
});
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("bounds a stalled result-file read before cleaning up", async () => {
const runner = queuedRunner([completed(0), completed(0)]);
const fileSystem = launchdFileSystem({});
fileSystem.readOptionalFile.mockReturnValueOnce(new Promise(() => undefined));
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 502,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 10,
pollIntervalMs: 1,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "timeout",
});
expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => {
const runner = queuedRunner([completed(0), completed(1, "", "bootout denied")]);
const fileSystem = launchdFileSystem({
"/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"),
});
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 503,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("bootout denied");
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("boots out a label when bootstrap itself times out", async () => {
const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" },
completed(0),
]);
const fileSystem = launchdFileSystem({});
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 504,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "timeout",
});
expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("treats an explicit not-loaded bootout response as successful cleanup after bootstrap fails", async () => {
const runner = queuedRunner([
completed(1, "", "bootstrap denied"),
completed(3, "", "Could not find service in domain"),
]);
const fileSystem = launchdFileSystem({});
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 504,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("bootstrap denied");
expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("cleans a loaded label after malformed private result output", async () => {
const runner = queuedRunner([completed(0), completed(0)]);
const fileSystem = launchdFileSystem({ "/tmp/probe/result.log": "malformed result" });
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 505,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "malformed-output",
});
expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("cleans a loaded label when the private result cannot be read", async () => {
const runner = queuedRunner([completed(0), completed(0)]);
const fileSystem = launchdFileSystem({});
fileSystem.readOptionalFile.mockRejectedValueOnce(new Error("read denied"));
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 506,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("Could not read launchd probe result");
expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("reports temporary-file cleanup failures", async () => {
const runner = queuedRunner([
completed(1, "", "bootstrap denied"),
completed(3, "", "Could not find service in domain"),
]);
const fileSystem = launchdFileSystem({});
fileSystem.removeDirectory.mockRejectedValueOnce(new Error("rm denied"));
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 506,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("rm denied");
});
});
describe("probe service definitions", () => {
it("requires external executables instead of accepting shell functions or aliases", () => {
const commandRequirement = request().prerequisites[0];
if (commandRequirement === undefined) throw new Error("Expected a command prerequisite");
const bashCheck = nativeServicePrerequisiteShellCheck("bash", commandRequirement);
expect(bashCheck).toContain("case \"$pi_web_probe_executable\" in */*)");
expect(bashCheck).toContain("test -f \"$pi_web_probe_executable\"");
expect(bashCheck).toContain("test -x \"$pi_web_probe_executable\"");
const fishCheck = nativeServicePrerequisiteShellCheck("fish", commandRequirement);
expect(fishCheck).toContain("string match -q '*/*'");
expect(fishCheck).toContain("test -f $pi_web_probe_executable[1]");
expect(fishCheck).toContain("test -x $pi_web_probe_executable[1]");
});
it("invokes the resolved external Node executable for version checks", () => {
const check = nativeServicePrerequisiteShellCheck("zsh", {
id: "sessiond.node",
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22",
});
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
expect(check).not.toContain("&& node -e");
});
it("requires bundled entrypoints to be readable regular files", () => {
const check = nativeServicePrerequisiteShellCheck("bash", {
id: "sessiond.entrypoint",
kind: "readable-file",
path: "/package/server.js",
description: "entrypoint",
});
expect(check).toBe("test -f '/package/server.js' && test -r '/package/server.js'");
});
it("renders backend inputs without inheriting the caller PATH", () => {
const probeRequest = request();
const systemdArguments = systemdRunArguments(probeRequest, "probe.service", "echo ok");
expect(systemdArguments.some((argument) => argument.includes("PATH="))).toBe(false);
const plist = launchdProbePlist(probeRequest, "com.example.probe", "echo ok", "/tmp/out", "/tmp/err");
expect(plist).not.toContain("<key>PATH</key>");
expect(plist).toContain("<key>PI_WEB_CONFIG</key>");
expect(plist).toContain("<key>HardResourceLimits</key>");
});
it("escapes manager-side substitutions in the systemd probe payload", () => {
const args = systemdRunArguments(request(), "probe.service", "test -r '/tmp/$HOME/%h'");
expect(args.at(-1)).toBe("test -r '/tmp/$$HOME/%h'");
});
});
function launchdFileSystem(contents: Record<string, string>): LaunchdProbeFileSystem & {
writeFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["writeFile"]>>;
readOptionalFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["readOptionalFile"]>>;
removeDirectory: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["removeDirectory"]>>;
} {
const writeFileMock = vi.fn<LaunchdProbeFileSystem["writeFile"]>(() => Promise.resolve());
const readOptionalFileMock = vi.fn<LaunchdProbeFileSystem["readOptionalFile"]>((path) => Promise.resolve(contents[path] ?? null));
const removeDirectoryMock = vi.fn<LaunchdProbeFileSystem["removeDirectory"]>(() => Promise.resolve());
return {
createTemporaryDirectory: () => Promise.resolve("/tmp/probe"),
writeFile: writeFileMock,
readOptionalFile: readOptionalFileMock,
removeDirectory: removeDirectoryMock,
};
}
+591
View File
@@ -0,0 +1,591 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir, userInfo } from "node:os";
import { posix as posixPath } from "node:path";
import { performance } from "node:perf_hooks";
import { randomUUID } from "node:crypto";
import type {
NativeServiceAuthoritativeProbe,
NativeServicePrerequisite,
NativeServicePrerequisiteOutcome,
NativeServiceProbeRequest,
NativeServiceProbeResult,
NativeServiceShellName,
} from "./servicePlan.js";
export type ProbeCommandResult =
| { kind: "completed"; status: number; stdout: string; stderr: string }
| { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn-failure"; message: string; stdout: string; stderr: string }
| { kind: "output-limit"; stdout: string; stderr: string };
export interface ProbeCommandRunner {
run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult>;
}
export interface LaunchdProbeFileSystem {
createTemporaryDirectory(prefix: string): Promise<string>;
writeFile(path: string, contents: string, mode: number): Promise<void>;
readOptionalFile(path: string): Promise<string | null>;
removeDirectory(path: string): Promise<void>;
}
interface CommonProbeDependencies {
commandRunner: ProbeCommandRunner;
createUniqueId(): string;
commandTimeoutMs: number;
}
export type SystemdProbeDependencies = CommonProbeDependencies;
export interface LaunchdProbeDependencies extends CommonProbeDependencies {
fileSystem: LaunchdProbeFileSystem;
uid: number;
now(): number;
sleep(milliseconds: number): Promise<void>;
probeTimeoutMs: number;
pollIntervalMs: number;
}
const defaultCommandTimeoutMs = 15_000;
const defaultProbeTimeoutMs = 15_000;
const defaultPollIntervalMs = 50;
const maxCapturedCommandOutputBytes = 1024 * 1024;
const maxLaunchdProbeFileBytes = 1024 * 1024;
export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProbe {
public constructor(private readonly dependencies: SystemdProbeDependencies) {}
public async run(request: NativeServiceProbeRequest): Promise<NativeServiceProbeResult> {
if (request.backend.kind !== "systemd") {
return infrastructureFailure("manager", `Systemd probe cannot validate the ${request.backend.kind} backend.`);
}
const uniqueId = safeUniqueId(this.dependencies.createUniqueId());
const unitName = `pi-web-authoritative-probe-${uniqueId}.service`;
const outputPrefix = `PI_WEB_PROBE_${uniqueId}`;
const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix);
const args = systemdRunArguments(request, unitName, command);
const result = await this.dependencies.commandRunner.run("systemd-run", args, this.dependencies.commandTimeoutMs);
if (result.kind === "timeout" || result.kind === "output-limit") {
const cleanupFailure = await this.cleanupTimedOutUnit(unitName);
if (cleanupFailure !== null) return cleanupFailure;
return result.kind === "timeout"
? infrastructureFailure("timeout", `Timed out waiting for transient systemd unit ${unitName}.`)
: infrastructureFailure("manager", `Transient systemd probe ${unitName} exceeded the output limit.`);
}
if (result.kind === "spawn-failure") {
return infrastructureFailure("manager", `Could not start systemd-run: ${result.message}`);
}
if (result.status !== 0) {
return infrastructureFailure(
"manager",
`Transient systemd probe ${unitName} failed: ${firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`)}`,
);
}
return parseProbeOutput(result.stdout, request.prerequisites, outputPrefix);
}
private async cleanupTimedOutUnit(unitName: string): Promise<NativeServiceProbeResult | null> {
const stop = await this.dependencies.commandRunner.run(
"systemctl",
["--user", "stop", unitName],
this.dependencies.commandTimeoutMs,
);
const inspected = await this.dependencies.commandRunner.run(
"systemctl",
["--user", "show", unitName, "--property=LoadState", "--value"],
this.dependencies.commandTimeoutMs,
);
if (inspected.kind === "completed" && inspected.status === 0 && inspected.stdout.trim() === "not-found") {
return null;
}
const details = [
`stop: ${commandFailureDetail(stop)}`,
`load state: ${commandFailureDetail(inspected)}`,
].join("; ");
return infrastructureFailure("cleanup", `Could not confirm cleanup of timed-out transient systemd unit ${unitName}: ${details}`);
}
}
export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProbe {
public constructor(private readonly dependencies: LaunchdProbeDependencies) {}
public async run(request: NativeServiceProbeRequest): Promise<NativeServiceProbeResult> {
if (request.backend.kind !== "launchd") {
return infrastructureFailure("manager", `Launchd probe cannot validate the ${request.backend.kind} backend.`);
}
const uniqueId = safeUniqueId(this.dependencies.createUniqueId());
const label = `com.pi-web.authoritative-probe.${String(this.dependencies.uid)}.${uniqueId}`;
const domain = `gui/${String(this.dependencies.uid)}`;
const target = `${domain}/${label}`;
const outputPrefix = `PI_WEB_PROBE_${uniqueId}`;
let directory: string | null = null;
let bootstrapState: "not-loaded" | "loaded" | "uncertain" = "not-loaded";
let result: NativeServiceProbeResult;
try {
directory = await this.dependencies.fileSystem.createTemporaryDirectory(
posixPath.join(tmpdir(), "pi-web-launchd-probe-"),
);
const plistPath = posixPath.join(directory, "probe.plist");
const stdoutPath = posixPath.join(directory, "stdout.log");
const stderrPath = posixPath.join(directory, "stderr.log");
const pendingResultPath = posixPath.join(directory, "result.pending");
const resultPath = posixPath.join(directory, "result.log");
const command = prerequisiteProbeCommand(
request.shell.name,
request.prerequisites,
outputPrefix,
{ pendingPath: pendingResultPath, completedPath: resultPath },
);
await this.dependencies.fileSystem.writeFile(
plistPath,
launchdProbePlist(request, label, command, stdoutPath, stderrPath),
0o600,
);
const bootstrap = await this.dependencies.commandRunner.run(
"launchctl",
["bootstrap", domain, plistPath],
this.dependencies.commandTimeoutMs,
);
if (bootstrap.kind !== "completed" || bootstrap.status !== 0) {
bootstrapState = bootstrap.kind === "spawn-failure" ? "not-loaded" : "uncertain";
result = commandInfrastructureFailure("bootstrap launchd probe", bootstrap);
} else {
bootstrapState = "loaded";
result = await this.waitForResult(target, resultPath, request.prerequisites, outputPrefix);
}
} catch (error: unknown) {
result = infrastructureFailure("manager", `Could not prepare launchd probe: ${errorMessage(error)}`);
}
const cleanupFailure = await this.cleanup(target, directory, bootstrapState);
return cleanupFailure ?? result;
}
private async waitForResult(
target: string,
resultPath: string,
prerequisites: readonly NativeServicePrerequisite[],
outputPrefix: string,
): Promise<NativeServiceProbeResult> {
const deadline = this.dependencies.now() + this.dependencies.probeTimeoutMs;
while (this.dependencies.now() < deadline) {
const remainingMs = Math.max(0, deadline - this.dependencies.now());
const boundedRead = await readOptionalFileBounded(
this.dependencies.fileSystem,
resultPath,
remainingMs,
);
if (boundedRead.kind === "deadline") break;
if (boundedRead.kind === "read-failure") {
return infrastructureFailure("manager", `Could not read launchd probe result: ${errorMessage(boundedRead.error)}`);
}
if (boundedRead.output !== null) return parseProbeOutput(boundedRead.output, prerequisites, outputPrefix);
const pollDelayMs = Math.min(this.dependencies.pollIntervalMs, Math.max(0, deadline - this.dependencies.now()));
await this.dependencies.sleep(pollDelayMs);
}
return infrastructureFailure("timeout", `Timed out waiting for launchd probe ${target}.`);
}
private async cleanup(
target: string,
directory: string | null,
bootstrapState: "not-loaded" | "loaded" | "uncertain",
): Promise<NativeServiceProbeResult | null> {
const failures: string[] = [];
const shouldBootout = bootstrapState !== "not-loaded";
if (shouldBootout) {
// A failed or timed-out bootstrap may still have loaded the unique label.
// Bootout is the only race-free cleanup; an explicit not-loaded response
// is success when bootstrap completion was uncertain.
const absenceIsSuccess = bootstrapState === "uncertain";
const bootout = await this.dependencies.commandRunner.run(
"launchctl",
["bootout", target],
this.dependencies.commandTimeoutMs,
);
if (
(bootout.kind !== "completed" || bootout.status !== 0)
&& !(absenceIsSuccess && launchdTargetNotLoaded(bootout))
) {
failures.push(`bootout failed: ${commandFailureDetail(bootout)}`);
}
}
if (directory !== null) {
try {
await this.dependencies.fileSystem.removeDirectory(directory);
} catch (error: unknown) {
failures.push(`temporary-file removal failed: ${errorMessage(error)}`);
}
}
return failures.length === 0
? null
: infrastructureFailure("cleanup", `Launchd probe cleanup failed for ${target}: ${failures.join("; ")}`);
}
}
export function createNativeServiceAuthoritativeProbe(): NativeServiceAuthoritativeProbe {
const commandRunner = new SpawnProbeCommandRunner();
const common: CommonProbeDependencies = {
commandRunner,
createUniqueId: randomUUID,
commandTimeoutMs: defaultCommandTimeoutMs,
};
const systemd = new SystemdNativeServiceProbe(common);
const launchd = new LaunchdNativeServiceProbe({
...common,
fileSystem: nodeLaunchdProbeFileSystem,
uid: userInfo().uid,
now: performance.now.bind(performance),
sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
probeTimeoutMs: defaultProbeTimeoutMs,
pollIntervalMs: defaultPollIntervalMs,
});
return {
run: (request) => request.backend.kind === "systemd" ? systemd.run(request) : launchd.run(request),
};
}
export function systemdRunArguments(
request: NativeServiceProbeRequest,
unitName: string,
shellCommand: string,
): readonly string[] {
return [
"--user",
"--wait",
"--collect",
"--pipe",
"--quiet",
`--unit=${unitName}`,
"--property=RuntimeMaxSec=15s",
"--property=TimeoutStopSec=5s",
...Object.entries(request.environment).map(([key, value]) => `--setenv=${key}=${value}`),
...(request.workingDirectory === null ? [] : [`--working-directory=${request.workingDirectory}`]),
"/usr/bin/env",
escapeSystemdCommandExpansion(request.shell.executable),
"-lc",
escapeSystemdCommandExpansion(shellCommand),
];
}
function escapeSystemdCommandExpansion(value: string): string {
return value.replaceAll("$", () => "$$");
}
export function launchdProbePlist(
request: NativeServiceProbeRequest,
label: string,
shellCommand: string,
stdoutPath: string,
stderrPath: string,
): string {
const argumentsXml = ["/usr/bin/env", request.shell.executable, "-lc", shellCommand]
.map((argument) => ` <string>${xmlEscape(argument)}</string>`)
.join("\n");
const environmentEntries = Object.entries(request.environment);
const environmentXml = environmentEntries.length === 0
? ""
: ` <key>EnvironmentVariables</key>\n <dict>\n${environmentEntries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
const workingDirectoryXml = request.workingDirectory === null
? ""
: plistString("WorkingDirectory", request.workingDirectory);
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", label)} <key>ProgramArguments</key>
<array>
${argumentsXml}
</array>
${workingDirectoryXml}${environmentXml} <key>RunAtLoad</key>
<true/>
<key>HardResourceLimits</key>
<dict>
<key>FileSize</key>
<integer>${String(maxLaunchdProbeFileBytes)}</integer>
</dict>
${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)}</dict>
</plist>
`;
}
export class SpawnProbeCommandRunner implements ProbeCommandRunner {
public run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let capturedBytes = 0;
let spawnFailure: string | null = null;
let settled = false;
const finish = (result: ProbeCommandResult, terminate: boolean): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (terminate) {
child.kill("SIGKILL");
child.stdout.destroy();
child.stderr.destroy();
child.unref();
}
resolve(result);
};
const capture = (stream: "stdout" | "stderr", chunk: string): void => {
if (settled) return;
const bytes = Buffer.byteLength(chunk);
if (capturedBytes + bytes > maxCapturedCommandOutputBytes) {
finish({ kind: "output-limit", stdout, stderr }, true);
return;
}
capturedBytes += bytes;
if (stream === "stdout") stdout += chunk;
else stderr += chunk;
};
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => { capture("stdout", chunk); });
child.stderr.on("data", (chunk: string) => { capture("stderr", chunk); });
child.on("error", (error) => { spawnFailure = error.message; });
const timeout = setTimeout(() => {
finish({ kind: "timeout", stdout, stderr }, true);
}, timeoutMs);
child.on("close", (status) => {
if (spawnFailure !== null) {
finish({ kind: "spawn-failure", message: spawnFailure, stdout, stderr }, false);
} else {
finish({ kind: "completed", status: status ?? 1, stdout, stderr }, false);
}
});
});
}
}
const nodeLaunchdProbeFileSystem: LaunchdProbeFileSystem = {
createTemporaryDirectory: (prefix) => mkdtemp(prefix),
writeFile: (path, contents, mode) => writeFile(path, contents, { encoding: "utf8", mode }),
readOptionalFile: async (path) => {
try {
return await readFile(path, "utf8");
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return null;
throw error;
}
},
removeDirectory: (path) => rm(path, { recursive: true, force: true }),
};
function readOptionalFileBounded(
fileSystem: LaunchdProbeFileSystem,
path: string,
timeoutMs: number,
): Promise<
| { kind: "read"; output: string | null }
| { kind: "read-failure"; error: unknown }
| { kind: "deadline" }
> {
return new Promise((resolve) => {
let settled = false;
const finish = (result:
| { kind: "read"; output: string | null }
| { kind: "read-failure"; error: unknown }
| { kind: "deadline" }): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
};
const timeout = setTimeout(() => { finish({ kind: "deadline" }); }, timeoutMs);
void fileSystem.readOptionalFile(path).then(
(output) => { finish({ kind: "read", output }); },
(error: unknown) => { finish({ kind: "read-failure", error }); },
);
});
}
function prerequisiteProbeCommand(
shell: NativeServiceShellName,
prerequisites: readonly NativeServicePrerequisite[],
outputPrefix: string,
resultFiles?: { pendingPath: string; completedPath: string },
): string {
const markerPath = resultFiles?.pendingPath;
const checks = prerequisites.map((prerequisite) => {
const check = nativeServicePrerequisiteShellCheck(shell, prerequisite);
const encodedId = Buffer.from(prerequisite.id, "utf8").toString("base64");
const satisfied = markerCommand(shell, outputPrefix, encodedId, "satisfied", markerPath);
const unsatisfied = markerCommand(shell, outputPrefix, encodedId, "unsatisfied", markerPath);
return `${check} >/dev/null 2>&1 && ${satisfied} || ${unsatisfied}`;
}).join("; ") || ":";
if (resultFiles === undefined) return checks;
const pending = shellQuote(shell, resultFiles.pendingPath);
const completed = shellQuote(shell, resultFiles.completedPath);
return `printf '%s' '' > ${pending}; ${checks}; /bin/mv ${pending} ${completed}`;
}
export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string {
switch (prerequisite.kind) {
case "command-available":
return externalExecutableShellCheck(shell, prerequisite.command);
case "node-version": {
const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`;
return externalExecutableShellCheck(shell, "node", ["-e", script]);
}
case "readable-file": {
const path = shellQuote(shell, prerequisite.path);
return `test -f ${path} && test -r ${path}`;
}
case "package-scripts": {
const script = "const p=require(process.argv[1]);const names=process.argv.slice(2);process.exit(names.every((name)=>typeof p.scripts?.[name]==='string')?0:1)";
return externalExecutableShellCheck(shell, "node", ["-e", script, prerequisite.packageJsonPath, ...prerequisite.scripts]);
}
}
}
function externalExecutableShellCheck(
shell: NativeServiceShellName,
command: string,
arguments_: readonly string[] = [],
): string {
const quotedCommand = shellQuote(shell, command);
const quotedArguments = arguments_.map((argument) => shellQuote(shell, argument)).join(" ");
if (shell === "fish") {
const invocation = quotedArguments === "" ? "" : `; and $pi_web_probe_executable[1] ${quotedArguments}`;
return `set -l pi_web_probe_executable (command -v ${quotedCommand}); and test (count $pi_web_probe_executable) -eq 1; and string match -q '*/*' -- $pi_web_probe_executable[1]; and test -f $pi_web_probe_executable[1]; and test -x $pi_web_probe_executable[1]${invocation}`;
}
const invocation = quotedArguments === "" ? "" : ` && "$pi_web_probe_executable" ${quotedArguments}`;
return `pi_web_probe_executable=$(command -v ${quotedCommand}) && case "$pi_web_probe_executable" in */*) test -f "$pi_web_probe_executable" && test -x "$pi_web_probe_executable"${invocation};; *) false;; esac`;
}
function markerCommand(
shell: NativeServiceShellName,
outputPrefix: string,
encodedId: string,
status: "satisfied" | "unsatisfied",
outputPath?: string,
): string {
const redirect = outputPath === undefined ? "" : ` >> ${shellQuote(shell, outputPath)}`;
return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}${redirect}`;
}
function parseProbeOutput(
stdout: string,
prerequisites: readonly NativeServicePrerequisite[],
outputPrefix: string,
): NativeServiceProbeResult {
const expected = new Map(prerequisites.map((prerequisite) => [
Buffer.from(prerequisite.id, "utf8").toString("base64"),
prerequisite,
]));
const outcomes = new Map<string, NativeServicePrerequisiteOutcome>();
for (const line of stdout.split(/\r?\n/u)) {
if (!line.startsWith(`${outputPrefix}\t`)) continue;
const fields = line.split("\t");
if (fields.length !== 3) {
return infrastructureFailure("malformed-output", "Authoritative probe returned a malformed result line.");
}
const encodedId = fields[1];
const status = fields[2];
const prerequisite = encodedId === undefined ? undefined : expected.get(encodedId);
if (prerequisite === undefined || (status !== "satisfied" && status !== "unsatisfied")) {
return infrastructureFailure("malformed-output", "Authoritative probe returned an unexpected result.");
}
if (outcomes.has(prerequisite.id)) {
return infrastructureFailure("malformed-output", `Authoritative probe returned duplicate outcome ${prerequisite.id}.`);
}
outcomes.set(prerequisite.id, {
prerequisiteId: prerequisite.id,
status,
detail: status === "satisfied" ? null : unsatisfiedDetail(prerequisite),
});
}
const missing = prerequisites.find((prerequisite) => !outcomes.has(prerequisite.id));
if (missing !== undefined) {
return infrastructureFailure("malformed-output", `Authoritative probe returned no outcome for ${missing.id}.`);
}
return { kind: "completed", outcomes: [...outcomes.values()] };
}
function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
switch (prerequisite.kind) {
case "command-available":
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
case "node-version":
return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`;
case "readable-file":
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
case "package-scripts":
return `${prerequisite.packageJsonPath} did not provide scripts ${prerequisite.scripts.join(", ")} in the native service environment.`;
}
}
function shellQuote(shell: NativeServiceShellName, value: string): string {
return shell === "fish"
? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`
: `'${value.replaceAll("'", "'\\''")}'`;
}
function safeUniqueId(value: string): string {
const safe = value.toLowerCase().replaceAll(/[^a-z0-9-]/gu, "").slice(0, 48);
return safe === "" ? "probe" : safe;
}
function launchdTargetNotLoaded(result: ProbeCommandResult): boolean {
if (result.kind !== "completed" || result.status === 0) return false;
return /(?:could not find (?:specified )?service|service not found|no such process)/iu.test(`${result.stderr}\n${result.stdout}`);
}
function plistString(key: string, value: string, indent = " "): string {
return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
}
function xmlEscape(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function commandInfrastructureFailure(action: string, result: ProbeCommandResult): NativeServiceProbeResult {
if (result.kind === "timeout") return infrastructureFailure("timeout", `Timed out while trying to ${action}.`);
return infrastructureFailure("manager", `Could not ${action}: ${commandFailureDetail(result)}`);
}
function commandFailureDetail(result: ProbeCommandResult): string {
if (result.kind === "timeout") return "command timed out";
if (result.kind === "spawn-failure") return result.message;
if (result.kind === "output-limit") return "command output exceeded the capture limit";
return firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`);
}
function infrastructureFailure(
reason: "manager" | "timeout" | "malformed-output" | "cleanup",
message: string,
): NativeServiceProbeResult {
return { kind: "infrastructure-failure", reason, message };
}
function firstOutput(...values: string[]): string {
for (const value of values) {
const line = value.trim().split(/\r?\n/u).find((candidate) => candidate.trim() !== "");
if (line !== undefined) return line.trim();
}
return "no output";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
createDevelopmentNativeServicePlan,
type NativeServicePlan,
type NativeServicePlanService,
} from "./servicePlan.js";
import { renderLaunchdPlist, renderSystemdUnit } from "./serviceRendering.js";
function developmentPlan(kind: "systemd" | "launchd"): NativeServicePlan {
return createDevelopmentNativeServicePlan({
backend: { kind, label: kind },
shell: {
name: "zsh",
executable: "/bin/zsh",
source: "detected",
detectedExecutable: "/bin/zsh",
},
environment: { PI_WEB_CONFIG: "/home/user/config with \"quote\".json" },
workingDirectory: "/checkout with space",
packageJsonPath: "/checkout with space/package.json",
});
}
function planService(plan: NativeServicePlan, index: number): NativeServicePlanService {
const service = plan.services[index];
if (service === undefined) throw new Error(`Missing service at index ${String(index)}`);
return service;
}
describe("native service rendering", () => {
it("renders systemd entirely from the canonical plan", () => {
const plan = developmentPlan("systemd");
const unit = renderSystemdUnit(plan, planService(plan, 1));
expect(unit).toContain("Description=PI WEB UI dev server");
expect(unit).toContain("After=pi-web-sessiond.service\nWants=pi-web-sessiond.service");
expect(unit).toContain("WorkingDirectory=/checkout\\x20with\\x20space");
expect(unit).toContain('Environment="PI_WEB_CONFIG=/home/user/config with \\"quote\\".json"');
expect(unit).toContain('ExecStart=/usr/bin/env "/bin/zsh" -lc "exec /usr/bin/env bash -c \'trap \\"kill 0\\" EXIT;');
expect(unit).toContain("Restart=no");
});
it("escapes systemd specifiers and line controls without changing directives", () => {
const plan = createDevelopmentNativeServicePlan({
backend: { kind: "systemd", label: "systemd" },
shell: {
name: "bash",
executable: "/shell $HOME/%h/bash",
source: "detected",
detectedExecutable: "/shell $HOME/%h/bash",
},
environment: { PI_WEB_CONFIG: "/config/%h\nEnvironment=INJECTED=yes" },
workingDirectory: "/checkout %h\nwith newline",
packageJsonPath: "/checkout/package.json",
});
const unit = renderSystemdUnit(plan, planService(plan, 0));
expect(unit).toContain("WorkingDirectory=/checkout\\x20%%h\\nwith\\x20newline");
expect(unit).toContain('Environment="PI_WEB_CONFIG=/config/%%h\\nEnvironment=INJECTED=yes"');
expect(unit).toContain('ExecStart=/usr/bin/env "/shell $$HOME/%%h/bash"');
expect(unit.match(/^Environment=/gmu)).toHaveLength(1);
});
it("renders launchd entirely from the canonical plan", () => {
const plan = developmentPlan("launchd");
const plist = renderLaunchdPlist(plan, planService(plan, 0), "/logs");
expect(plist).toContain("<string>com.pi-web.sessiond</string>");
expect(plist).toContain("<string>/bin/zsh</string>");
expect(plist).toContain("<string>exec npm run start:sessiond</string>");
expect(plist).toContain("<key>WorkingDirectory</key>\n <string>/checkout with space</string>");
expect(plist).toContain("<key>PI_WEB_CONFIG</key>\n <string>/home/user/config with &quot;quote&quot;.json</string>");
expect(plist.match(/<string>\/logs\/sessiond\.log<\/string>/gu)).toHaveLength(2);
expect(plist).not.toContain("<string>\\logs\\sessiond.log</string>");
expect(plist).not.toContain("<key>KeepAlive</key>");
});
it("rejects a service from a different plan", () => {
const first = developmentPlan("systemd");
const second = developmentPlan("systemd");
expect(() => renderSystemdUnit(first, planService(second, 0))).toThrow("not a member");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { posix as posixPath } from "node:path";
import type {
NativeServiceId,
NativeServicePlan,
NativeServicePlanService,
} from "./servicePlan.js";
export function renderSystemdUnit(
plan: NativeServicePlan,
service: NativeServicePlanService,
): string {
assertPlanService(plan, service);
assertBackend(plan, "systemd");
const workingDirectory = service.workingDirectory === null
? ""
: `WorkingDirectory=${systemdPathValue(service.workingDirectory)}\n`;
const restart = service.restart === "on-failure"
? "Restart=on-failure\nRestartSec=2\n"
: "Restart=no\n";
return `[Unit]
Description=${service.description}
${systemdDependencyLine(plan, "After", service.after)}${systemdDependencyLine(plan, "Wants", service.wants)}[Service]
Type=simple
${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${systemdExecArgument(plan.shell.executable)} -lc ${systemdExecArgument(service.shellCommand)}
${restart}
[Install]
WantedBy=default.target
`;
}
export function renderLaunchdPlist(
plan: NativeServicePlan,
service: NativeServicePlanService,
logDirectory: string,
): string {
assertPlanService(plan, service);
assertBackend(plan, "launchd");
const programArguments = ["/usr/bin/env", plan.shell.executable, "-lc", service.shellCommand];
const workingDirectory = service.workingDirectory === null
? ""
: plistString("WorkingDirectory", service.workingDirectory);
const keepAlive = service.restart === "on-failure"
? " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n"
: "";
const logPath = posixPath.join(logDirectory, service.manager.logName);
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.manager.launchdLabel)}${plistProgramArguments(programArguments)}${workingDirectory}${plistEnvironment(service.environment)} <key>RunAtLoad</key>
<true/>
${keepAlive}${plistString("StandardOutPath", logPath)}${plistString("StandardErrorPath", logPath)}</dict>
</plist>
`;
}
function assertPlanService(plan: NativeServicePlan, service: NativeServicePlanService): void {
if (!plan.services.includes(service)) {
throw new Error(`Cannot render ${service.id}; it is not a member of the supplied native service plan.`);
}
}
function assertBackend(plan: NativeServicePlan, expected: "systemd" | "launchd"): void {
if (plan.backend.kind !== expected) {
throw new Error(`Cannot render ${expected} service from a ${plan.backend.kind} native service plan.`);
}
}
function systemdDependencyLine(
plan: NativeServicePlan,
name: "After" | "Wants",
ids: readonly NativeServiceId[],
): string {
if (ids.length === 0) return "";
const names = ids.map((id) => {
const dependency = plan.services.find((service) => service.id === id);
if (dependency === undefined) throw new Error(`Service ${id} is not present in the native service plan.`);
return dependency.manager.systemdName;
});
return `${name}=${names.join(" ")}\n`;
}
function systemdEnvironmentLines(environment: Readonly<Record<string, string>>): string {
return Object.entries(environment)
.map(([key, value]) => `Environment=${systemdQuotedDirectiveValue(`${key}=${value}`)}\n`)
.join("");
}
function systemdExecArgument(value: string): string {
return `"${systemdEscape(value.replaceAll("%", "%%").replaceAll("$", () => "$$"), false)}"`;
}
function systemdQuotedDirectiveValue(value: string): string {
return `"${systemdEscape(value.replaceAll("%", "%%"), false)}"`;
}
function systemdPathValue(value: string): string {
return systemdEscape(value.replaceAll("%", "%%"), true);
}
function systemdEscape(value: string, escapeSpaces: boolean): string {
let escaped = "";
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (character === "\\") escaped += "\\\\";
else if (character === '"') escaped += escapeSpaces ? "\\x22" : '\\"';
else if (character === "'" && escapeSpaces) escaped += "\\x27";
else if (character === " " && escapeSpaces) escaped += "\\x20";
else if (character === "\n") escaped += "\\n";
else if (character === "\r") escaped += "\\r";
else if (character === "\t") escaped += "\\t";
else if (code < 0x20 || code === 0x7f) escaped += `\\x${code.toString(16).padStart(2, "0")}`;
else escaped += character;
}
return escaped;
}
function plistProgramArguments(arguments_: readonly string[]): string {
return ` <key>ProgramArguments</key>\n <array>\n${arguments_.map((argument) => ` <string>${xmlEscape(argument)}</string>`).join("\n")}\n </array>\n`;
}
function plistEnvironment(environment: Readonly<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 plistString(key: string, value: string, indent = " "): string {
return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
}
function xmlEscape(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
+2
View File
@@ -99,6 +99,8 @@ export interface PluginRuntimeContext {
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
/** Force a fresh PI WEB release check on the selected machine. Optional for compatibility with older hosts. */
checkForPiWebUpdates?: () => void | Promise<void>;
reloadPage: () => void;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { buildApp } from "./app.js";
describe("PI WEB status routes", () => {
it("forces a fresh status load when refresh is requested", async () => {
const get = vi.fn(() => Promise.resolve(status("cached")));
const refresh = vi.fn(() => Promise.resolve(status("forced")));
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
try {
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
expect(cachedResponse.json<PiWebStatusResponse>().generatedAt).toBe("cached");
expect(forcedResponse.json<PiWebStatusResponse>().generatedAt).toBe("forced");
expect(get).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledWith({ force: true });
} finally {
await app.close();
}
});
});
function status(generatedAt: string): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
+5
View File
@@ -24,6 +24,11 @@ describe("buildApp PI WEB plugin routes", () => {
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.body).toBe("export default {};");
const svgResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/assets/icon.svg" });
expect(svgResponse.statusCode).toBe(200);
expect(svgResponse.headers["content-type"]).toContain("image/svg+xml");
expect(svgResponse.body).toContain("<svg");
const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
expect(missingResponse.statusCode).toBe(404);
});
+17
View File
@@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("preserves the force-refresh query when proxying update checks", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ ok: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined);
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
+8 -1
View File
@@ -100,7 +100,7 @@ export function registerAppTestHooks(): void {
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
readAsset: fakePiWebPluginAsset,
},
clientDist: false,
logger: false,
@@ -123,6 +123,13 @@ export function registerAppTestHooks(): void {
});
}
function fakePiWebPluginAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
if (pluginId !== "fake") return Promise.resolve(undefined);
if (assetPath === "plugin.js") return Promise.resolve({ content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" });
if (assetPath === "assets/icon.svg") return Promise.resolve({ content: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'), contentType: "image/svg+xml" });
return Promise.resolve(undefined);
}
export interface CapturedSessionDaemonRequest {
method: string;
path: string;
+9 -5
View File
@@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
@@ -38,6 +38,7 @@ export interface AppDependencies {
sessionDaemon?: SessionProxyDaemon;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
piWebStatusCache?: PiWebStatusCache;
config?: PiWebConfigService;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
);
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
});
@@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
? piWebStatusCache.refresh({ force: true })
: piWebStatusCache.get());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
+111 -2
View File
@@ -39,20 +39,24 @@ describe("Docker command assets", () => {
execUtf8("sh", ["-n", dockerEntrypoint], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env),
execUtf8("bash", ["-n", join(repoRoot, "docker", "internal", "dev", "sync-node-modules")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
]);
});
it("packages the canonical Docker command and internal support assets", async () => {
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dockerignore] = await Promise.all([
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dependencySync, dockerignore] = await Promise.all([
readRepoFile("docker/Dockerfile"),
readRepoFile("docker/Dockerfile.dev"),
readRepoFile("docker/compose.yml"),
readRepoFile("docker/compose.dev.yml"),
readRepoFile("docker/install.sh"),
readRepoFile("docker/internal/dev/compose"),
readRepoFile("docker/internal/dev/sync-node-modules"),
readRepoFile("docker/.dockerignore"),
]);
const customImageHooksIndex = devDockerfile.indexOf("for script in /tmp/pi-web-custom-image.d/*.sh");
const dependencyGenerationIndex = devDockerfile.indexOf("/opt/pi-web-dev-dependencies/generation");
expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
@@ -62,6 +66,12 @@ describe("Docker command assets", () => {
expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@");
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec");
expect(devDockerfile).toContain("COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules");
expect(devDockerfile).toContain("/opt/pi-web-dev-dependencies/node_modules");
// Hooks can mutate the dependency seed, so its cache generation must be finalized afterward.
expect(customImageHooksIndex).toBeGreaterThanOrEqual(0);
expect(dependencyGenerationIndex).toBeGreaterThan(customImageHooksIndex);
expect(dependencySync).toContain(".pi-web-dev-dependency-generation");
expect(dockerignore).toContain("!pi-web-docker");
expect(dockerignore).toContain("!internal/bin/hostexec");
expect(installer).toContain("write_asset pi-web-docker 0755");
@@ -83,6 +93,8 @@ describe("Docker command assets", () => {
expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}");
expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}");
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
expect(devCompose).toContain("/usr/local/sbin/pi-web-dev-sync-node-modules");
expect(devCompose.match(/volumes: \*pi-web-dev-volumes/g)).toHaveLength(3);
});
dockerCommandIt("fetches remote installer assets without clobbering the write target", async () => {
@@ -247,6 +259,84 @@ describe("Docker command assets", () => {
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
});
dockerCommandIt("refuses development updates when the checkout has uncommitted files", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "staged.txt"), "changed\n", "utf8");
await execUtf8("git", ["-C", devRoot, "add", "staged.txt"], cleanProcessEnv());
await writeFile(join(devRoot, "modified.txt"), "changed\n", "utf8");
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("refusing to update the Docker development stack because the checkout has uncommitted changes");
expect(result.stderr).toContain("staged.txt");
expect(result.stderr).toContain("modified.txt");
expect(result.stderr).toContain("?? untracked.txt");
expect(result.stderr).toContain("commit, stash, or remove these changes");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses dirty development updates before scheduling a detached helper", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(["--dev", "update"], devRuntimeEnv(fakeDocker, devRoot));
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("checkout has uncommitted changes");
expect(result.stdout).not.toContain("Started detached PI WEB Docker helper");
await expect(readFile(fakeDocker.logPath, "utf8")).rejects.toThrow();
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses development updates while a Git operation is in progress", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const head = (await execUtf8("git", ["-C", devRoot, "rev-parse", "HEAD"], cleanProcessEnv())).stdout.trim();
await writeFile(join(devRoot, ".git", "MERGE_HEAD"), `${head}\n`, "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("while a Git merge is in progress");
expect(result.stderr).toContain("resolve or abort the Git merge");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("allows clean development updates and dirty development starts", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const env = devHostEnv(fakeDocker, devRoot, join(tempDir, "home"));
await runDockerCommand(["--dev", "update"], env);
await writeFile(join(devRoot, "in-progress-work.txt"), "dirty by design\n", "utf8");
await runDockerCommand(["--dev", "start"], env);
expect(await readFile(helperLog, "utf8")).toBe([
"allow=0 args=build --pull",
"allow=0 args=up -d --force-recreate --remove-orphans",
"allow=0 args=up -d --build",
"",
].join("\n"));
});
dockerCommandIt("starts development detached helpers as the generated dev user", async () => {
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
const fakeDocker = await installFakeDocker();
@@ -431,12 +521,31 @@ async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<stri
await mkdir(dirname(helperPath), { recursive: true });
await writeFile(helperPath, `#!/usr/bin/env sh
set -eu
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >${shellSingleQuote(logPath)}
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >>${shellSingleQuote(logPath)}
`, "utf8");
await chmod(helperPath, 0o755);
return devRoot;
}
async function createCleanDevGitRepoWithFakeHelper(logPath: string): Promise<string> {
const devRoot = await createDevRepoFixtureWithFakeHelper(logPath);
await Promise.all([
writeFile(join(devRoot, "staged.txt"), "clean\n", "utf8"),
writeFile(join(devRoot, "modified.txt"), "clean\n", "utf8"),
]);
const env = cleanProcessEnv();
await execUtf8("git", ["init", "--quiet", devRoot], env);
await execUtf8("git", ["-C", devRoot, "add", "."], env);
await execUtf8("git", [
"-C", devRoot,
"-c", "user.name=PI WEB Test",
"-c", "[email protected]",
"-c", "core.hooksPath=/dev/null",
"commit", "--quiet", "--no-gpg-sign", "-m", "test fixture",
], env);
return devRoot;
}
async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
const devRoot = join(tempDir, "dev-runtime");
await mkdir(join(devRoot, ".pi-web"), { recursive: true });
+102
View File
@@ -0,0 +1,102 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const syncScript = join(repoRoot, "docker", "internal", "dev", "sync-node-modules");
const dockerSyncIt = it.skipIf(process.platform === "win32");
let tempDir = "";
interface SyncFixture {
workspaceDir: string;
seedDir: string;
targetDir: string;
}
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-dependencies-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("Docker development dependency synchronization", () => {
dockerSyncIt("replaces a stale dependency tree once per image generation", async () => {
const fixture = await createSyncFixture();
const first = await runSync(fixture);
expect(first.exitCode).toBe(0);
expect(first.stderr).toContain("Synchronizing PI WEB Docker dev dependencies");
expect(await readFile(join(fixture.targetDir, "fresh", "version.txt"), "utf8")).toBe("0.80.6\n");
expect(await readlink(join(fixture.targetDir, ".bin", "fresh"))).toBe("../fresh/version.txt");
expect(await readFile(join(fixture.targetDir, ".pi-web-dev-dependency-generation"), "utf8")).toBe("image-generation-2\n");
await expect(readFile(join(fixture.targetDir, "stale.txt"), "utf8")).rejects.toThrow();
await writeFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "kept\n", "utf8");
const second = await runSync(fixture);
expect(second.exitCode).toBe(0);
expect(second.stderr).toContain("dependencies are current");
expect(await readFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "utf8")).toBe("kept\n");
});
dockerSyncIt("fails without changing the volume when the image manifests are stale", async () => {
const fixture = await createSyncFixture();
await writeFile(join(fixture.workspaceDir, "package-lock.json"), '{"lockfileVersion":3,"changed":true}\n', "utf8");
const result = await runSync(fixture);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("development image dependencies do not match the checkout");
expect(await readFile(join(fixture.targetDir, "stale.txt"), "utf8")).toBe("stale\n");
});
});
async function createSyncFixture(): Promise<SyncFixture> {
const workspaceDir = join(tempDir, "workspace");
const seedDir = join(tempDir, "seed");
const targetDir = join(workspaceDir, "node_modules");
const packageJson = '{"name":"dependency-sync-fixture","private":true}\n';
const packageLock = '{"name":"dependency-sync-fixture","lockfileVersion":3}\n';
await Promise.all([
mkdir(join(seedDir, "node_modules", "fresh"), { recursive: true }),
mkdir(join(seedDir, "node_modules", ".bin"), { recursive: true }),
mkdir(targetDir, { recursive: true }),
]);
await Promise.all([
writeFile(join(workspaceDir, "package.json"), packageJson, "utf8"),
writeFile(join(workspaceDir, "package-lock.json"), packageLock, "utf8"),
writeFile(join(seedDir, "package.json"), packageJson, "utf8"),
writeFile(join(seedDir, "package-lock.json"), packageLock, "utf8"),
writeFile(join(seedDir, "generation"), "image-generation-2\n", "utf8"),
writeFile(join(seedDir, "node_modules", "fresh", "version.txt"), "0.80.6\n", "utf8"),
writeFile(join(targetDir, "stale.txt"), "stale\n", "utf8"),
writeFile(join(targetDir, ".pi-web-dev-dependency-generation"), "image-generation-1\n", "utf8"),
]);
await symlink("../fresh/version.txt", join(seedDir, "node_modules", ".bin", "fresh"));
return { workspaceDir, seedDir, targetDir };
}
function runSync(fixture: SyncFixture): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolvePromise) => {
execFile("bash", [syncScript], {
encoding: "utf8",
env: {
...process.env,
PI_WEB_DEV_WORKSPACE_DIR: fixture.workspaceDir,
PI_WEB_DEV_DEPENDENCY_SEED_DIR: fixture.seedDir,
},
}, (error, stdout, stderr) => {
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
resolvePromise({ stdout, stderr, exitCode });
});
});
}
+38
View File
@@ -44,6 +44,44 @@ describe("PiWebPluginService", () => {
expect(asset?.content.toString("utf8")).toContain("export default");
});
it("preserves content types for extension-only asset names", async () => {
const pluginDir = join(tempDir, "plugins", "extension-only");
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "extension-only", module: ".js" }] } },
files: {
".js": "export default {};",
".svg": '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
},
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
await expect(service.readAsset("extension-only", ".js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" });
await expect(service.readAsset("extension-only", ".svg")).resolves.toMatchObject({ contentType: "image/svg+xml" });
});
it("serves nested SVG assets with a browser-compatible content type", async () => {
const pluginDir = join(tempDir, "plugins", "icons");
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>';
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "icons", module: "pi-web-plugin.js" }] } },
files: {
"pi-web-plugin.js": "export default {};",
"assets/icon.svg": svg,
"assets/uppercase.SVG": svg,
"assets/data.bin": "unknown",
},
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const svgAsset = await service.readAsset("icons", "assets/icon.svg");
expect(svgAsset?.contentType).toBe("image/svg+xml");
expect(svgAsset?.content.toString("utf8")).toBe(svg);
await expect(service.readAsset("icons", "assets/uppercase.SVG")).resolves.toMatchObject({ contentType: "image/svg+xml" });
await expect(service.readAsset("icons", "assets/data.bin")).resolves.toMatchObject({ contentType: "application/octet-stream" });
});
it("includes machine-specific preferences in plugin manifests", async () => {
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
+6 -4
View File
@@ -335,10 +335,12 @@ function isWithin(root: string, candidate: string): boolean {
}
function contentTypeFor(path: string): string {
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".html")) return "text/html; charset=utf-8";
const lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8";
if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8";
if (lowerPath.endsWith(".css")) return "text/css; charset=utf-8";
if (lowerPath.endsWith(".html")) return "text/html; charset=utf-8";
if (lowerPath.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js";
describe("createPiWebReleaseLookupCache", () => {
it("serves a fresh cached release lookup", async () => {
let now = 1_000;
const load = vi.fn(() => Promise.resolve("1.0.0"));
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
now = 1_050;
await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
expect(load).toHaveBeenCalledOnce();
expect(load).toHaveBeenCalledWith("0.9.0");
});
it("bypasses a fresh lookup when forced", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce("1.0.0")
.mockResolvedValueOnce("1.1.0");
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
await cache.get("0.9.0");
now = 1_050;
await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<string>();
const forced = createDeferred<string>();
const load = vi.fn()
.mockImplementationOnce(() => regular.promise)
.mockImplementationOnce(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const regularLookup = cache.get("0.9.0");
const forcedLookup = cache.get("0.9.0", { force: true });
if (completionOrder === "forced-first") {
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
} else {
regular.resolve("1.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
forced.resolve("2.0.0");
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
}
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledTimes(2);
});
it("makes regular callers join a pending forced lookup", async () => {
const forced = createDeferred<string>();
const load = vi.fn(() => forced.promise);
const cache = createPiWebReleaseLookupCache(load);
const forcedLookup = cache.get("0.9.0", { force: true });
const regularLookup = cache.get("0.9.0");
expect(regularLookup).toBe(forcedLookup);
forced.resolve("2.0.0");
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
expect(load).toHaveBeenCalledOnce();
});
});
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
+57
View File
@@ -0,0 +1,57 @@
const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
export interface PiWebReleaseLookup {
checkedAtMs: number;
latestVersion?: string;
error?: string;
}
export interface PiWebReleaseLookupCacheOptions {
ttlMs?: number;
now?: () => number;
}
export interface PiWebReleaseLookupOptions {
force?: boolean;
}
export interface PiWebReleaseLookupCache {
get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise<PiWebReleaseLookup>;
}
export function createPiWebReleaseLookupCache(
load: (currentVersion: string) => Promise<string>,
options: PiWebReleaseLookupCacheOptions = {},
): PiWebReleaseLookupCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: PiWebReleaseLookup | undefined;
let pending: { promise: Promise<PiWebReleaseLookup>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
return {
get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise<PiWebReleaseLookup> {
const force = lookupOptions.force === true;
if (pending?.force === true) return pending.promise;
const checkedAtMs = now();
if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached);
if (!force && pending !== undefined) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load(currentVersion))
.then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion }))
.catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) }))
.then((lookup) => {
if (sequence === loadSequence) cached = lookup;
return lookup;
})
.finally(() => {
if (pending?.sequence === sequence) pending = undefined;
});
pending = { promise, force, sequence };
return promise;
},
};
}
+32 -1
View File
@@ -69,6 +69,33 @@ describe("PI WEB status", () => {
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
});
it("bypasses cached npm release data for a forced check", async () => {
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(npmVersionResponse("1.202607.1"))
.mockResolvedValueOnce(npmVersionResponse("1.202607.2"));
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202607.0",
installedVersion: "1.202607.0",
stale: false,
available: true,
installation: { kind: "docker", dockerMode: "runtime" },
});
const first = await getPiWebStatus(daemon, { forceReleaseCheck: true });
const cached = await getPiWebStatus(daemon);
const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(first.release.latestVersion).toBe("1.202607.1");
expect(cached.release.latestVersion).toBe("1.202607.1");
expect(forced.release.latestVersion).toBe("1.202607.2");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
@@ -82,7 +109,7 @@ describe("PI WEB status", () => {
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
});
const status = await getPiWebStatus(daemon);
const status = await getPiWebStatus(daemon, { forceReleaseCheck: true });
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
@@ -192,6 +219,10 @@ describe("PI WEB status", () => {
});
});
function npmVersionResponse(version: string): Response {
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
}
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
+10 -16
View File
@@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
type ServiceId = "sessiond" | "web" | "uiDev";
@@ -74,8 +74,11 @@ interface PiWebStatusDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
export interface PiWebStatusOptions {
forceReleaseCheck?: boolean;
}
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
const runtimePackageInfo = readPackageInfoSync();
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
@@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
};
}
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon);
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond };
const commands = await commandsFor(components);
const messages = buildMessages(components, release, commands);
@@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus {
};
}
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise<PiWebReleaseStatus> {
const checkedAtMs = Date.now();
if (skipVersionCheck()) {
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
}
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
return releaseStatusFromCache(latestReleaseCache, currentVersion);
}
try {
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
} catch (error) {
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
}
return releaseStatusFromCache(latestReleaseCache, currentVersion);
return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion);
}
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus {
return {
packageName: PI_WEB_PACKAGE_NAME,
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
+40 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
@@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => {
expect(load).toHaveBeenCalledTimes(2);
});
it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => {
const regular = createDeferred<PiWebStatusResponse>();
const forced = createDeferred<PiWebStatusResponse>();
const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise);
const cache = createPiWebStatusCache(load);
const regularRefresh = cache.refresh();
const forcedRefresh = cache.refresh({ force: true });
if (completionOrder === "forced-first") {
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
} else {
regular.resolve(status("regular"));
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
forced.resolve(status("forced"));
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
}
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" });
expect(load).toHaveBeenNthCalledWith(1, { force: false });
expect(load).toHaveBeenNthCalledWith(2, { force: true });
});
it("makes regular refreshes join a pending forced refresh", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const forced = cache.refresh({ force: true });
const regular = cache.refresh();
expect(regular).toBe(forced);
deferred.resolve(status("forced"));
await forced;
expect(load).toHaveBeenCalledOnce();
});
it("retains stale status and reports background refresh errors", async () => {
let now = 1_000;
const refreshError = new Error("refresh failed");
+25 -11
View File
@@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions {
onError?: (error: unknown) => void;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
export interface PiWebStatusCacheLoadOptions {
force: boolean;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
export interface PiWebStatusCacheRefreshOptions {
force?: boolean;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
}
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
let pending: { promise: Promise<PiWebStatusResponse>; force: boolean; sequence: number } | undefined;
let loadSequence = 0;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise<PiWebStatusResponse> => {
const force = refreshOptions.force === true;
if (pending !== undefined && (!force || pending.force)) return pending.promise;
const sequence = ++loadSequence;
const promise = Promise.resolve()
.then(() => load({ force }))
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
if (pending?.sequence === sequence) pending = undefined;
});
return pending;
pending = { promise, force, sequence };
return promise;
};
return {