Merge branch 'main' into fix/pr-46-base-path

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 19:55:14 +02:00
committed by GitHub
23 changed files with 4288 additions and 390 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.
+20 -2
View File
@@ -36,12 +36,22 @@ WORKDIR /workspace
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs 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 \ 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 \ && npm cache clean --force \
&& chmod -R a+rwX /workspace/node_modules /data \ && chmod -R a+rwX /data \
&& chmod 0777 /workspace && 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/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
COPY docker/internal/bin/hostexec /usr/local/bin/hostexec COPY docker/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 RUN chmod 0755 /usr/local/bin/hostexec /usr/local/bin/pi-web-docker
COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/ 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 '\ RUN bash -euxo pipefail -c '\
shopt -s nullglob; \ shopt -s nullglob; \
for script in /tmp/pi-web-custom-image.d/*.sh; do \ for script in /tmp/pi-web-custom-image.d/*.sh; do \
@@ -56,9 +68,15 @@ RUN bash -euxo pipefail -c '\
bash "${script}"; \ bash "${script}"; \
done; \ done; \
rm -rf /tmp/pi-web-custom-image.d; \ 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; \ zypper clean --all; \
rm -rf /var/cache/zypp/* \ 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 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` | `./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-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. | | `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. | | `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. | | `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. | | `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 ./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: You can run the dev stack in the background with:
```bash ```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. 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 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.
./docker/pi-web-docker --dev stop
docker volume rm pi-web-dev_node_modules
./docker/pi-web-docker --dev start
```
## Local checkout validation ## Local checkout validation
+2 -2
View File
@@ -56,14 +56,14 @@ services:
set -euo pipefail set -euo pipefail
mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent 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 chown -R "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" /data
/usr/local/sbin/pi-web-dev-sync-node-modules
user: "0:0" user: "0:0"
security_opt: security_opt:
- label=disable - label=disable
environment: environment:
PI_WEB_UID: ${PI_WEB_UID:-1000} PI_WEB_UID: ${PI_WEB_UID:-1000}
PI_WEB_GID: ${PI_WEB_GID:-1000} PI_WEB_GID: ${PI_WEB_GID:-1000}
volumes: volumes: *pi-web-dev-volumes
- *pi-web-dev-data-volume
sessiond: sessiond:
build: *pi-web-dev-build 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-web Restart only the web service
restart-sessiond Restart only the session daemon restart-sessiond Restart only the session daemon
update Rebuild/update and recreate the Docker stack update Rebuild/update and recreate the Docker stack
(development mode requires a clean Git checkout)
status Show Docker Compose service status status Show Docker Compose service status
logs [web|sessiond|data-init] logs [web|sessiond|data-init]
Follow Docker Compose logs 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" [ "$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() { enforce_container_mode_match() {
is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || return 0 is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || return 0
runtime_mode=${PI_WEB_DOCKER_MODE:-} runtime_mode=${PI_WEB_DOCKER_MODE:-}
@@ -401,6 +456,7 @@ run_runtime_host_update() {
run_update() { run_update() {
assert_no_args update "$@" assert_no_args update "$@"
require_clean_dev_update_checkout
case "$(docker_mode)" in case "$(docker_mode)" in
runtime) runtime)
if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then
@@ -729,6 +785,11 @@ run_restart_or_update() {
shift shift
assert_no_args "$action" "$@" assert_no_args "$action" "$@"
if is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then 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" start_detached_helper "$action"
return 0 return 0
fi fi
+12 -8
View File
@@ -131,8 +131,9 @@
<h2>Tools are failing, node is not found, or Pi cannot find commands</h2> <h2>Tools are failing, node is not found, or Pi cannot find commands</h2>
<p> <p>
The shell environment needs to be set up so login shells have the required PATH entries for PI WEB, Pi, 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 and any tools your agents need. PI WEB services run commands through a non-interactive login shell owned
an interactive terminal can work while services fail. by systemd or launchd, so an interactive terminal—or even a caller-invoked login shell—can work while the
native service fails.
</p> </p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
@@ -156,14 +157,17 @@
<article id="doctor-fails" class="faq-item"> <article id="doctor-fails" class="faq-item">
<h2>What does <code>pi-web doctor</code> check?</h2> <h2>What does <code>pi-web doctor</code> check?</h2>
<p> <p>
It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi It keeps two kinds of checks separate. General login-shell readiness covers Node 22+, npm, Pi, and optional
Web binaries. It also prints installed and running PI WEB versions when available, reports optional ripgrep ripgrep. Native-service diagnostics validate only the exact prerequisites of the selected service plan in
availability for faster all-file <code>@</code>-mention suggestions, uses a bounded filesystem fallback when the real systemd user-manager or launchd <code>gui/&lt;uid&gt;</code> context. Development installs follow their
ripgrep is unavailable, and reports user service lingering when relevant for server-style installs. installed checkout plan; production checks are clearly labelled prospective when the installed executable
strategy cannot be reconstructed safely.
</p> </p>
<p> <p>
If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and Missing plan requirements fail doctor and include login-file guidance. Manager, timeout, malformed-output,
move the setup earlier in your shell startup chain. 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> </p>
</article> </article>
+2 -2
View File
@@ -138,8 +138,8 @@
# laptop, phone, tablet — same live sessions # laptop, phone, tablet — same live sessions
<span class="prompt">$</span> pi-web doctor <span class="prompt">$</span> pi-web doctor
✓ login shell can find node >= 22 caller login shell can find node >= 22
✓ native service shell can find pi ✓ native-service plan requirements pass in manager context
✓ ready for persistent agent work</code></pre> ✓ ready for persistent agent work</code></pre>
</aside> </aside>
</div> </div>
+6 -2
View File
@@ -113,8 +113,11 @@
</ul> </ul>
<div class="callout warning"> <div class="callout warning">
<strong>Important PATH detail:</strong> <strong>Important PATH detail:</strong>
PI WEB services run through your login shell with <code>-lc</code>. Setup that only lives in interactive shell PI WEB services run through a non-interactive login shell with <code>-lc</code>. Setup that only lives in
files or prompt hooks may not be visible to services. Run <code>pi-web doctor</code> after installing. 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> </div>
</section> </section>
@@ -135,6 +138,7 @@
<span class="prompt">$</span> pi-web doctor</code></pre> <span class="prompt">$</span> pi-web doctor</code></pre>
</div> </div>
<p>Then open <a href="http://127.0.0.1:8504">http://127.0.0.1:8504</a>.</p> <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> <p>On Linux servers, also consider <code>sudo loginctl enable-linger "$USER"</code> so user services survive logout/reboot.</p>
</section> </section>
+44 -1
View File
@@ -2,7 +2,14 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; 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"]; 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", () => { describe("isCliEntrypoint", () => {
it("matches direct execution paths", () => { it("matches direct execution paths", () => {
expect(isCliEntrypoint("/tmp/pi-web-cli.js", "/tmp/pi-web-cli.js")).toBe(true); 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 #!/usr/bin/env node
import { spawnSync } from "node:child_process"; 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 { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os"; import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; 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 { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.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"; 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 launchdServiceDir = join(homedir(), "Library", "LaunchAgents");
const logDir = join(defaultPiWebDataDir(), "logs"); 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 InstallMode = "production" | "dev";
type ServiceBackendKind = "systemd" | "launchd"; type ServiceId = NativeServiceId;
type ServiceId = "sessiond" | "web" | "uiDev"; type ServiceBackend = NativeServiceBackend;
type Check = [string, string[]]; type Check = [string, string[]];
type SupportedShell = "bash" | "zsh" | "fish";
type RestartPolicy = "on-failure" | "never";
interface InstallOptions { interface InstallOptions {
host: string; host: string;
@@ -33,44 +58,8 @@ interface InstallOptions {
config?: string; config?: string;
} }
interface ServiceBackend { interface ServiceRef extends NativeServiceManagerRef {
kind: ServiceBackendKind;
label: string;
}
interface ServiceRef {
id: ServiceId; 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"; type ServiceHealth = "running" | "stopped" | "not-installed" | "unknown";
@@ -85,30 +74,12 @@ interface ServiceRuntimeStatus {
} }
const serviceRefs: Record<ServiceId, ServiceRef> = { const serviceRefs: Record<ServiceId, ServiceRef> = {
sessiond: { sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond },
id: "sessiond", web: { id: "web", ...nativeServiceManagerRefs.web },
systemdName: sessiondServiceName, uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev },
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",
},
}; };
const productionServiceIds: ServiceId[] = ["sessiond", "web"]; const productionServiceIds: ServiceId[] = [...productionNativeServiceIds];
const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"]; const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"]; const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"];
// Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web // Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web
@@ -123,12 +94,16 @@ function platformLabel(): string {
return process.platform; return process.platform;
} }
function currentServiceBackend(): ServiceBackend | undefined { export function serviceBackendForPlatform(platform: NodeJS.Platform): ServiceBackend | undefined {
if (process.platform === "linux") return { kind: "systemd", label: "systemd user services" }; if (platform === "linux") return { kind: "systemd", label: "systemd user services" };
if (process.platform === "darwin") return { kind: "launchd", label: "LaunchAgents" }; if (platform === "darwin") return { kind: "launchd", label: "LaunchAgents" };
return undefined; return undefined;
} }
function currentServiceBackend(): ServiceBackend | undefined {
return serviceBackendForPlatform(process.platform);
}
function requireServiceBackend(command: string): ServiceBackend { function requireServiceBackend(command: string): ServiceBackend {
const backend = currentServiceBackend(); const backend = currentServiceBackend();
if (backend !== undefined) return backend; if (backend !== undefined) return backend;
@@ -236,23 +211,6 @@ function fishSingleQuote(value: string): string {
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; 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 { function packageRootPath(): string {
return dirname(dirname(fileURLToPath(import.meta.url))); 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"); 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 userShell = userInfo().shell ?? undefined;
const envShell = process.env["SHELL"]?.trim(); const envShell = process.env["SHELL"]?.trim();
const detected = envShell === undefined || envShell === "" ? userShell : envShell; const detected = envShell === undefined || envShell === "" ? userShell : envShell;
const name = basename(detected ?? "").replace(/^-/, ""); const name = basename(detected ?? "").replace(/^-/, "");
if (name === "bash" || name === "zsh" || name === "fish") { 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[] { 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]; return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand];
} }
function serviceShellExecPrefix(): string {
return `/usr/bin/env ${detectServiceShell().executable} -lc`;
}
function serviceShellQuote(value: string): string { function serviceShellQuote(value: string): string {
return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value); 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 { function describeServiceShell(): string {
const shell = detectServiceShell(); const shell = detectServiceShell();
if (shell.fallback) { if (shell.source === "fallback") {
return shell.detected === undefined return shell.detectedExecutable === null
? "could not detect a supported login shell; using bash" ? "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> { function configEnvironment(options: InstallOptions, configPath: string): Record<string, string> {
@@ -385,28 +299,6 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] {
return orderServiceRefs(refs, restartServiceOrder); 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 { function devRootPath(): string {
return resolve(process.cwd()); return resolve(process.cwd());
} }
@@ -421,104 +313,21 @@ function validateDevCheckout(root: string): void {
if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) { 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}.`); 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 { function launchdLogPath(ref: ServiceRef): string {
return join(logDir, ref.logName); return join(logDir, ref.logName);
} }
function launchdPlist(service: ServiceDefinition): string { function installConfigPath(options: InstallOptions): string {
const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory); return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
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>
`;
} }
async function writeInitialConfig(options: InstallOptions): Promise<string> { async function writeInitialConfig(options: InstallOptions, configPath: string): Promise<void> {
const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
await mkdir(dirname(configPath), { recursive: true }); await mkdir(dirname(configPath), { recursive: true });
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) })); await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) }));
} }
return configPath;
} }
function systemdServicePath(ref: ServiceRef): string { function systemdServicePath(ref: ServiceRef): string {
@@ -546,8 +355,8 @@ function installedServiceRefs(backend: ServiceBackend): ServiceRef[] {
return installed.length === 0 ? productionServiceRefs() : installed; return installed.length === 0 ? productionServiceRefs() : installed;
} }
async function installSystemdServices(services: ServiceDefinition[]): Promise<void> { async function installSystemdServices(plan: NativeServicePlan): Promise<void> {
const selected = new Set<ServiceId>(services.map((service) => service.id)); const selected = new Set<ServiceId>(plan.services.map((service) => service.id));
const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id))); const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
for (const ref of obsolete) { for (const ref of obsolete) {
@@ -556,11 +365,11 @@ async function installSystemdServices(services: ServiceDefinition[]): Promise<vo
} }
await mkdir(systemdServiceDir, { recursive: true }); await mkdir(systemdServiceDir, { recursive: true });
for (const service of services) { for (const service of plan.services) {
await writeFile(systemdServicePath(service), systemdUnit(service)); 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", "daemon-reload"], { check: true });
run("systemctl", ["--user", "enable", ...names], { check: true }); run("systemctl", ["--user", "enable", ...names], { check: true });
run("systemctl", ["--user", "restart", ...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 }); run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true });
} }
async function installLaunchdServices(services: ServiceDefinition[]): Promise<void> { async function installLaunchdServices(plan: NativeServicePlan): Promise<void> {
const selected = new Set<ServiceId>(services.map((service) => service.id)); const selected = new Set<ServiceId>(plan.services.map((service) => service.id));
await mkdir(launchdServiceDir, { recursive: true }); await mkdir(launchdServiceDir, { recursive: true });
await mkdir(logDir, { recursive: true }); await mkdir(logDir, { recursive: true });
@@ -604,16 +413,21 @@ async function installLaunchdServices(services: ServiceDefinition[]): Promise<vo
await rm(launchdPlistPath(ref), { force: true }); await rm(launchdPlistPath(ref), { force: true });
} }
for (const service of services) { for (const service of plan.services) {
await writeFile(launchdPlistPath(service), launchdPlist(service)); 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> { async function installNativeServices(plan: NativeServicePlan): Promise<void> {
if (backend.kind === "systemd") await installSystemdServices(services); if (plan.backend.kind === "systemd") await installSystemdServices(plan);
else await installLaunchdServices(services); else await installLaunchdServices(plan);
}
function serviceRefFromPlan(id: ServiceId, manager: NativeServiceManagerRef): ServiceRef {
return { id, ...manager };
} }
async function uninstallSystemdServices(): Promise<void> { async function uninstallSystemdServices(): Promise<void> {
@@ -705,6 +519,16 @@ function parseLaunchdField(output: string, field: string): string | undefined {
return match?.[1]?.trim(); 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 { function launchdRuntimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus {
const target = launchdServiceTarget(ref); const target = launchdServiceTarget(ref);
const filePath = serviceFilePath(backend, 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); return makeServiceRuntimeStatus(ref, "stopped", firstOutputLine(result.stderr, result.stdout) ?? "not loaded", target, filePath);
} }
const state = parseLaunchdField(result.stdout, "state") ?? "unknown"; const details = launchdRuntimeDetails(result.stdout);
const pid = parseLaunchdField(result.stdout, "pid"); const health: ServiceHealth = details.state === "running" ? "running" : details.state === "unknown" ? "unknown" : "stopped";
const health: ServiceHealth = state === "running" ? "running" : state === "unknown" ? "unknown" : "stopped"; return makeServiceRuntimeStatus(ref, health, details.detail, target, filePath, details.pid);
return makeServiceRuntimeStatus(ref, health, state === "running" ? "running" : state, target, filePath, pid);
} }
function runtimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus { function runtimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus {
@@ -747,40 +570,86 @@ function printServiceStatusReport(backend: ServiceBackend): boolean {
return statuses.every((status) => status.health === "running"); return statuses.every((status) => status.health === "running");
} }
function backendAvailabilityChecks(backend: ServiceBackend): Check[] { function configuredServiceCommand(name: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC"): string | undefined {
if (backend.kind === "systemd") return [["systemctl --user", ["systemctl", "--user", "--version"]]]; const value = process.env[name];
return [[`launchctl ${launchdDomain()}`, ["launchctl", "print", launchdDomain()]]]; return value === undefined || value.trim() === "" ? undefined : value;
} }
function baseShellChecks(backend: ServiceBackend): Check[] { function productionNativeServicePlanInput(
const shell = serviceShellLabel(); backend: ServiceBackend,
const checks: Check[] = [[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())]]; shell: NativeServiceShell,
if (backend.kind === "systemd") checks.push([`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())]); environment: Readonly<Record<string, string>>,
return checks; ): 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[] { function nativeServiceInstallCandidate(
const shell = serviceShellLabel(); options: InstallOptions,
const checks: Check[] = [ backend: ServiceBackend,
[`${shell} can find npm`, serviceShellCommand(commandCheck("npm"), root)], configPath: string,
[`${shell} can find bash`, serviceShellCommand(commandCheck("bash"), root)], devRoot: string | undefined,
]; ): NativeServiceInstallCandidate {
if (backend.kind === "systemd") { const shell = detectServiceShell();
checks.push( const environment = configEnvironment(options, configPath);
[`systemd user ${shell} can find npm`, systemdUserServiceShellCommand(commandCheck("npm"), root)], if (options.mode === "production") {
[`systemd user ${shell} can find bash`, systemdUserServiceShellCommand(commandCheck("bash"), root)], 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[] { function printNativeServiceInstallFailure(failure: NativeServiceInstallFailure): void {
return [ if (failure.kind === "plan-resolution") {
...backendAvailabilityChecks(backend), for (const item of failure.failures) {
...baseShellChecks(backend), if (item.kind === "probe-infrastructure") {
...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []), console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []), } 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> { async function install(args: string[]): Promise<void> {
@@ -788,22 +657,26 @@ async function install(args: string[]): Promise<void> {
const options = parseInstallOptions(args); const options = parseInstallOptions(args);
const devRoot = options.mode === "dev" ? devRootPath() : undefined; const devRoot = options.mode === "dev" ? devRootPath() : undefined;
if (devRoot !== undefined) validateDevCheckout(devRoot); 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(`Running PI WEB ${options.mode} install preflight checks...`);
console.log(`Service backend: ${backend.label}`); console.log(`Service backend: ${backend.label}`);
console.log(`Service shell: ${describeServiceShell()}`); console.log(`Service shell: ${describeServiceShell()}`);
if (!runChecks(installPreflightChecks(backend, options.mode, executables, devRoot))) { const result = await installNativeServiceCandidate(candidate, {
printPathSetupAdvice(); probe: createNativeServiceAuthoritativeProbe(),
throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail."); 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(`\nPI WEB ${options.mode} services are installed and starting.`);
console.log(`Config: ${configPath}`); console.log(`Config: ${configPath}`);
@@ -886,18 +759,6 @@ function serviceShellLabel(): string {
return `${detectServiceShell().name} -lc`; 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 { function commandCheck(command: string): string {
return `command -v ${command}`; return `command -v ${command}`;
} }
@@ -917,29 +778,13 @@ function nodeVersionCheck(): string {
].join(" && "); ].join(" && ");
} }
function doctorChecks(): Check[] { function generalDoctorChecks(): Check[] {
const shell = serviceShellLabel(); const shell = serviceShellLabel();
const backend = currentServiceBackend();
if (backend === undefined) {
return [ return [
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())], [`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], [`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], [`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 { function runChecks(checks: Check[]): boolean {
@@ -966,10 +811,7 @@ function printCheckOutput(output: string): void {
function optionalDoctorChecks(): Check[] { function optionalDoctorChecks(): Check[] {
const shell = serviceShellLabel(); const shell = serviceShellLabel();
const backend = currentServiceBackend(); return [[`Caller login ${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
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;
} }
function printOptionalDoctorChecks(): void { function printOptionalDoctorChecks(): void {
@@ -989,8 +831,115 @@ function printOptionalDoctorChecks(): void {
} }
} }
function printPathSetupAdvice(): void { function installedServiceDefinitions(
const shell = detectServiceShell(); 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:"); console.log("\nPATH setup advice:");
if (shell.name === "bash") { if (shell.name === "bash") {
console.log(" Detected bash. Put PATH setup for node/version managers/tools in ~/.bash_profile or ~/.profile."); 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> { async function doctor(): Promise<void> {
const backend = currentServiceBackend(); const backend = currentServiceBackend();
console.log(`Platform: ${platformLabel()}`); console.log(`Platform: ${platformLabel()}`);
console.log(`Service backend: ${backend?.label ?? "manual run only"}`); console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
console.log(`Service shell: ${describeServiceShell()}`); console.log(`Service shell: ${describeServiceShell()}`);
if (backend === undefined) { 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(""); console.log("");
await printPiWebVersionReport(); 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(); printOptionalDoctorChecks();
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); 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()) { if (supportsSystemdUserServices()) {
const linger = isLingerEnabled(); const linger = isLingerEnabled();
if (linger === true) { if (linger === true) {
@@ -1037,17 +1001,21 @@ async function doctor(): Promise<void> {
console.log(`- systemd user lingering skipped on ${platformLabel()}`); console.log(`- systemd user lingering skipped on ${platformLabel()}`);
} }
if (!ok) { const nativeServicePlanOk = nativeServiceReport?.ok ?? true;
console.log("\nIf a command works in your terminal but fails here, make sure your service shell login files set PATH the same way."); const pathFailure = !generalReadinessOk || nativeServiceReport?.pathAdviceRecommended === true;
if (backend?.kind === "systemd") console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package."); if (pathFailure) {
printPathSetupAdvice(); 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()}`); console.log(`\n${manualRunAdvice()}`);
} }
if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1; if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtySpawnHelperOk) !== 0) process.exitCode = 1;
} }
function printNodePtyDarwinSpawnHelperCheck(): boolean { function printNodePtyDarwinSpawnHelperCheck(): boolean {
+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;");
}
+111 -2
View File
@@ -39,20 +39,24 @@ describe("Docker command assets", () => {
execUtf8("sh", ["-n", dockerEntrypoint], process.env), execUtf8("sh", ["-n", dockerEntrypoint], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env), execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env), execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "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), execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
]); ]);
}); });
it("packages the canonical Docker command and internal support assets", async () => { 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"),
readRepoFile("docker/Dockerfile.dev"), readRepoFile("docker/Dockerfile.dev"),
readRepoFile("docker/compose.yml"), readRepoFile("docker/compose.yml"),
readRepoFile("docker/compose.dev.yml"), readRepoFile("docker/compose.dev.yml"),
readRepoFile("docker/install.sh"), readRepoFile("docker/install.sh"),
readRepoFile("docker/internal/dev/compose"), readRepoFile("docker/internal/dev/compose"),
readRepoFile("docker/internal/dev/sync-node-modules"),
readRepoFile("docker/.dockerignore"), 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 pi-web-docker /usr/local/bin/pi-web-docker");
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec"); expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
@@ -62,6 +66,12 @@ describe("Docker command assets", () => {
expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@"); 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/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 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("!pi-web-docker");
expect(dockerignore).toContain("!internal/bin/hostexec"); expect(dockerignore).toContain("!internal/bin/hostexec");
expect(installer).toContain("write_asset pi-web-docker 0755"); 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_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("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("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 () => { 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"); 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 () => { dockerCommandIt("starts development detached helpers as the generated dev user", async () => {
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 }); const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
const fakeDocker = await installFakeDocker(); const fakeDocker = await installFakeDocker();
@@ -431,12 +521,31 @@ async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<stri
await mkdir(dirname(helperPath), { recursive: true }); await mkdir(dirname(helperPath), { recursive: true });
await writeFile(helperPath, `#!/usr/bin/env sh await writeFile(helperPath, `#!/usr/bin/env sh
set -eu 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"); `, "utf8");
await chmod(helperPath, 0o755); await chmod(helperPath, 0o755);
return devRoot; 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> { async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
const devRoot = join(tempDir, "dev-runtime"); const devRoot = join(tempDir, "dev-runtime");
await mkdir(join(devRoot, ".pi-web"), { recursive: true }); 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 });
});
});
}