From 743c01e34b6703da8f9153c50fdf115fe96aaf83 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 17:49:51 -0500 Subject: [PATCH] feat(webui): the Console as a user service (#1569, row 75) scripts/webui-service.sh render|install|uninstall|status and the packages/webui/systemd/mosaic-webui.service.in template, on the bus-service.sh pattern. install writes the unit to a temp file, renames it and runs only `systemctl --user daemon-reload`. There is no start, stop, enable or restart verb: the Console reads the bus through the human transport, so Jason starts it from his own shell (lead decisions 62 and 94). Serve's flags pass through, including row 74's preview set, each held to a character set the unit can carry. The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable and does not restart on serve's exit 2. Tests in packages/webui/tests/service.test.mjs; TOOLS.md entry. Author: Rocko. Candidate manifest 7dc571dbe87a484f465c82e1965e499c57d9af047bfe702faa3fe041138dd154 (agents/rocko/work/queue-75/, base 43d2328f, no drift on the manifest paths to 8b351c3d). Approved by Filbert, comment 27430 on #1569, with five non-blocking notes placed in row 77. Sage gate rerun on a worktree of 8b351c3d with the patch: manifest OK; webui 50/50; test-auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 27, release 14, task 98, all passed, 0 failed. Co-Authored-By: Claude Opus 5.5 --- docs/TOOLS.md | 31 +++ .../webui/systemd/mosaic-webui.service.in | 35 +++ packages/webui/tests/service.test.mjs | 220 ++++++++++++++++++ scripts/webui-service.sh | 208 +++++++++++++++++ 4 files changed, 494 insertions(+) create mode 100644 packages/webui/systemd/mosaic-webui.service.in create mode 100644 packages/webui/tests/service.test.mjs create mode 100755 scripts/webui-service.sh diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 9a28a090c..631e18146 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -238,6 +238,37 @@ stdin and stdout it exits 4 with the usage. Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input · `3` refused or config problem · `4` usage. Details: `packages/cli/README.md`. +## Console service (`scripts/webui-service.sh`) + +```bash +scripts/webui-service.sh render [FLAGS] # print the unit +scripts/webui-service.sh install [--dir DIR] [--no-reload] [FLAGS] +scripts/webui-service.sh uninstall [--dir DIR] [--no-reload] +scripts/webui-service.sh status [--dir DIR] +# FLAGS: [--port N] [--business ID] [--board URL] +# [--public-host NAME --auth-header HEADER --allow-user ID] +``` + +The systemd user unit `mosaic-webui.service` runs the Console, +`node packages/webui/src/cli.mjs serve`, with serve's flags as given: +`--port` defaults to 7332 (7330 is the operator's own Console), and the +preview set goes all three or none. The template is +`packages/webui/systemd/mosaic-webui.service.in`. `install` writes a temp +file, renames it and runs `systemctl --user daemon-reload`; `uninstall` +refuses while the unit is active; `status` prints the unit state and +`GET /healthz` on the installed port. Each value is held to a plain +character set the unit can carry (no space, `%`, `$`, quote, backslash, +`&` or `|`) and refused otherwise. The unit sets no `MOSAIC_*`, `CLAUDE*`, +`CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a port +already in use) is not restarted. + +There is no start, stop, enable or restart verb. The Console reads the bus +through the human transport, so whoever starts it holds the human's +capability; an agent starting it, even through systemd, works around the +human proof (lead decisions 62 and 94). Jason starts it from his own shell: +`systemctl --user enable --now mosaic-webui`. +Exit codes: `0` ok · `1` failed or refused · `4` usage. + ## Discord connector (`scripts/discord.sh`) ```bash diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in new file mode 100644 index 000000000..081bd963d --- /dev/null +++ b/packages/webui/systemd/mosaic-webui.service.in @@ -0,0 +1,35 @@ +# Mosaic Console: `node packages/webui/src/cli.mjs serve`, one instance. +# Rendered by scripts/webui-service.sh from packages/webui/systemd/; @REPO@, +# @PATH@, @NODE@ and the serve flags below are filled in at install time. +# Edit the template and reinstall; do not edit the installed copy. +# +# The Console reads the bus through the human transport, so whoever starts +# this unit holds the human's capability. Jason starts it from his own +# shell (`systemctl --user enable --now mosaic-webui`); an agent starting +# it works around the human proof (lead decisions 62 and 94). The unit sets +# no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable. +[Unit] +Description=Mosaic Console +Documentation=file://@REPO@/packages/webui/README.md +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=@REPO@ +Environment=PATH=@PATH@ +# The server binds 127.0.0.1 only. Usage, config and listen refusals +# (a port already in use included) exit 2 and are not retried; fix the +# flags or free the port, then start it again. +ExecStart=@NODE@ @REPO@/packages/webui/src/cli.mjs serve @FLAGS@ +Restart=on-failure +RestartSec=15 +RestartPreventExitStatus=2 +# `systemctl --user stop` sends SIGTERM; the server stops accepting and +# closes. Open connections that outlast TimeoutStopSec are killed. +KillSignal=SIGTERM +TimeoutStopSec=30 +NoNewPrivileges=yes + +[Install] +WantedBy=default.target diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs new file mode 100644 index 000000000..d50b6652b --- /dev/null +++ b/packages/webui/tests/service.test.mjs @@ -0,0 +1,220 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or +// reaches the real systemd: install and uninstall run with --no-reload or a +// stand-in systemctl on PATH, and XDG_CONFIG_HOME points into a temp dir. +const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, ''); +const SCRIPT = join(REPO, 'scripts', 'webui-service.sh'); +const NODE_DIR = dirname(process.execPath); +const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', 'jason']; + +function tmp(t) { + const dir = mkdtempSync(join(tmpdir(), 'webui-service-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +// A stand-in systemctl that prints `lines` for list-units and logs every call. +function standIn(dir, lines = '') { + const bin = join(dir, 'bin'); + mkdirSync(bin); + writeFileSync(join(dir, 'list-units.txt'), lines); + writeFileSync(join(bin, 'systemctl'), `#!/bin/sh +echo "$*" >> "${dir}/calls.txt" +case "$*" in + *list-units*) cat "${dir}/list-units.txt" ;; + *is-enabled*) echo enabled ;; + *is-active*) echo active ;; +esac +exit 0 +`); + chmodSync(join(bin, 'systemctl'), 0o755); + return bin; +} + +// Async, so an in-process server can answer the script's /healthz request. +async function run(dir, args, bin = null) { + const env = { PATH: `${bin ? `${bin}:` : ''}${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; + const child = spawn(SCRIPT, args, { env }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + const [status] = await once(child, 'close'); + return { status, stdout, stderr }; +} + +const execStart = (unit) => unit.split('\n').find((l) => l.startsWith('ExecStart=')); + +test('render prints the unit without the preview flags', async (t) => { + const dir = tmp(t); + const r = await run(dir, ['render']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, new RegExp(`^WorkingDirectory=${REPO.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')); + assert.match(r.stdout, /^Environment=PATH=\S*\/usr\/local\/bin:\/usr\/bin:\/bin$/m); + assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332`); + for (const line of ['Restart=on-failure', 'RestartPreventExitStatus=2', 'NoNewPrivileges=yes', 'WantedBy=default.target']) { + assert.ok(r.stdout.split('\n').includes(line), line); + } + assert.doesNotMatch(r.stdout, /@[A-Z]+@/, 'every placeholder is filled'); +}); + +test('render carries every serve flag, the preview set included', async (t) => { + const dir = tmp(t); + const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]); + assert.equal(r.status, 0, r.stderr); + assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user jason`); +}); + +test('a partial preview set is refused', async (t) => { + const dir = tmp(t); + for (const args of [PREVIEW.slice(0, 2), PREVIEW.slice(2, 6), [...PREVIEW.slice(0, 2), ...PREVIEW.slice(4, 6)]]) { + const r = await run(dir, ['render', ...args]); + assert.equal(r.status, 1, args.join(' ')); + assert.equal(r.stdout, ''); + assert.match(r.stderr, /all three or none/); + } +}); + +test('a value the unit cannot carry is refused, for every flag', async (t) => { + const dir = tmp(t); + const set = (flag, value) => { + const preview = PREVIEW.slice(); + const i = preview.indexOf(flag); + if (i >= 0) { preview[i + 1] = value; return preview; } + return [flag, value]; + }; + for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) { + for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) { + const value = flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`; + const r = await run(dir, ['render', ...set(flag, value)]); + assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`); + assert.equal(r.stdout, '', `${flag} ${JSON.stringify(value)}`); + } + } + for (const port of ['0', '65536', '7a', '', '-1', '999999']) { + const r = await run(dir, ['render', '--port', port]); + assert.equal(r.status, 1, `port ${JSON.stringify(port)}`); + } + for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) { + const r = await run(dir, ['render', ...args]); + assert.equal(r.status, 1, args.join(' ')); + } +}); + +test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => { + const dir = tmp(t); + const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8'); + const markers = [...source.match(/const MARKERS = \[([^\]]*)\]/)[1].matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]); + assert.ok(markers.length >= 7 && markers.includes('MOSAIC_BUS_CAP') && markers.includes('CLAUDECODE'), markers.join(' ')); + for (const args of [[], [...PREVIEW, '--business', 'mosaic']]) { + const r = await run(dir, ['render', ...args]); + assert.equal(r.status, 0, r.stderr); + for (const marker of markers) assert.ok(!r.stdout.includes(marker), marker); + const envLines = r.stdout.split('\n').filter((l) => /^\s*(Environment|EnvironmentFile|PassEnvironment|UnsetEnvironment)\s*=/.test(l)); + assert.deepEqual(envLines.map((l) => l.split('=')[1]), ['PATH']); + assert.doesNotMatch(r.stdout, /^[^#]*\b(MOSAIC_|CLAUDE|CODEX|PI_)/m); + } +}); + +test('install into --dir with --no-reload writes the file whole, then reports unchanged', async (t) => { + const dir = tmp(t); + const unitDir = join(dir, 'units'); + const bin = standIn(dir); + const rendered = await run(dir, ['render', '--business', 'mosaic']); + const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin); + assert.equal(r.status, 0, r.stderr); + const target = join(unitDir, 'mosaic-webui.service'); + assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm')); + assert.match(r.stdout, /systemctl --user enable --now mosaic-webui/); + assert.equal(readFileSync(target, 'utf8'), rendered.stdout); + assert.equal(statSync(target).mode & 0o777, 0o644); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service'], 'no temp file left behind'); + const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin); + assert.equal(again.status, 0, again.stderr); + assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm')); + assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call with --no-reload'); + // A refused value writes nothing and leaves the installed unit alone. + const bad = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'a b'], bin); + assert.equal(bad.status, 1); + assert.equal(readFileSync(target, 'utf8'), rendered.stdout); + assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']); + const fresh = join(dir, 'never'); + assert.equal((await run(dir, ['install', '--dir', fresh, '--no-reload', '--port', '0'], bin)).status, 1); + assert.ok(!existsSync(fresh), 'a refusal creates no directory'); +}); + +test('install without --no-reload runs daemon-reload and nothing that starts the unit', async (t) => { + const dir = tmp(t); + const bin = standIn(dir); + const r = await run(dir, ['install', '--dir', join(dir, 'units')], bin); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^daemon-reload done$/m); + assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user daemon-reload\n'); +}); + +test('uninstall refuses while the unit is active, and removes it once it is not', async (t) => { + const dir = tmp(t); + const unitDir = join(dir, 'units'); + const target = join(unitDir, 'mosaic-webui.service'); + for (const state of ['active', 'activating', 'deactivating']) { + const bin = standIn(mkdtempSync(join(dir, 'si-')), `mosaic-webui.service loaded ${state} running Mosaic Console\n`); + assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload'])).status, 0); + const r = await run(dir, ['uninstall', '--dir', unitDir], bin); + assert.equal(r.status, 1, state); + assert.match(r.stderr, /refusing: mosaic-webui\.service is still running/); + assert.ok(existsSync(target), `still installed while ${state}`); + } + const bin = standIn(mkdtempSync(join(dir, 'si-')), 'mosaic-webui.service loaded inactive dead Mosaic Console\n'); + const r = await run(dir, ['uninstall', '--dir', unitDir], bin); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, new RegExp(`^removed: ${target}$`, 'm')); + assert.match(r.stdout, /^daemon-reload done$/m); + assert.ok(!existsSync(target)); + const absent = await run(dir, ['uninstall', '--dir', unitDir, '--no-reload'], bin); + assert.equal(absent.status, 0); + assert.match(absent.stdout, /^absent: /m); +}); + +test('status prints the unit state and GET /healthz on the installed port', async (t) => { + const dir = tmp(t); + const unitDir = join(dir, 'units'); + const bin = standIn(dir); + const server = createServer((req, res) => { + if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}\n'); return; } + res.writeHead(404); res.end(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => server.close()); + const { port } = server.address(); + assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port)])).status, 0); + const r = await run(dir, ['status', '--dir', unitDir], bin); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^ {2}enabled: enabled$/m); + assert.match(r.stdout, /^ {2}active: {2}active$/m); + assert.match(r.stdout, new RegExp(`^healthz: http://127\\.0\\.0\\.1:${port}/healthz 200 \\{"ok":true\\}$`, 'm')); + server.close(); + await once(server, 'close'); + const down = await run(dir, ['status', '--dir', unitDir], bin); + assert.equal(down.status, 0, down.stderr); + assert.match(down.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m); + const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin); + assert.match(none.stdout, /^healthz: not checked; no unit at /m); +}); + +test('usage: an unknown verb or flag, and start or enable, exit 4', async (t) => { + const dir = tmp(t); + for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload']]) { + const r = await run(dir, args); + assert.equal(r.status, 4, args.join(' ')); + assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/); + } +}); diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh new file mode 100755 index 000000000..4ff068338 --- /dev/null +++ b/scripts/webui-service.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# `scripts/webui-service.sh render|install|uninstall|status`: the systemd +# user unit that runs the Console, `node packages/webui/src/cli.mjs serve` +# (lead decision 94). The template is +# packages/webui/systemd/mosaic-webui.service.in; the rendered unit is +# mosaic-webui.service in the systemd user directory, one instance. +# +# render [FLAGS] print the rendered unit on stdout +# install [--dir DIR] [--no-reload] [FLAGS] +# write the unit (write to a temp file, then +# rename) and run `systemctl --user daemon-reload` +# uninstall [--dir DIR] [--no-reload] +# remove the unit; refuses while it is active +# status [--dir DIR] unit state, then GET /healthz on its port +# +# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own +# Console), --business ID, --board URL, and the preview set --public-host +# NAME --auth-header HEADER --allow-user ID, all three or none. systemd +# expands % and $ and splits on spaces, so each value is held to a plain +# character set and refused otherwise. None is a secret. +# +# There is no start, stop, enable or restart verb. The Console reads the +# bus through the human transport, so whoever starts it holds the human's +# capability. An agent starting it, even detached through systemd, works +# around the human proof (lead decisions 62 and 94). Jason starts it from +# his own shell: `systemctl --user enable --now mosaic-webui`. +# +# Exit codes: 0 ok, 1 operation failed, 4 usage. +set -euo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in" +UNIT_NAME="mosaic-webui.service" +USAGE="usage: scripts/webui-service.sh render [FLAGS] | install [--dir DIR] [--no-reload] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] + FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID]" + +die() { echo "webui-service: $*" >&2; exit 1; } +usage() { echo "$USAGE" >&2; exit 4; } + +UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +RELOAD=1 +DIR_FLAGS=0 +PORT=7332 +BUSINESS="" +BOARD="" +PUBLIC_HOST="" +AUTH_HEADER="" +ALLOW_USER="" +SERVE_SET=0 + +parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;; + --no-reload) RELOAD=0; DIR_FLAGS=1; shift ;; + --port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;; + --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;; + --board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;; + --public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;; + --auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;; + --allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;; + *) usage ;; + esac + done +} + +# The serve flags as one ExecStart fragment. Each character set excludes +# space, %, $, quotes, backslash, & and |, so neither systemd nor the sed +# below can read a value as anything but itself. +serve_flags() { + case "$PORT" in ''|*[!0-9]*) die "port must be 1..65535: $PORT" ;; esac + [ "${#PORT}" -le 5 ] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "port must be 1..65535: $PORT" + local flags="--port $PORT" + if [ -n "$BUSINESS" ]; then + case "$BUSINESS" in [a-z0-9]*) ;; *) die "business must be a business id: $BUSINESS" ;; esac + case "$BUSINESS" in *[!a-z0-9._-]*) die "business must be a business id: $BUSINESS" ;; esac + [ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS" + flags="$flags --business $BUSINESS" + fi + if [ -n "$BOARD" ]; then + # serve checks the rest (a loopback origin, no path or credentials). + case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac + case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac + flags="$flags --board $BOARD" + fi + local preview=0 + [ -z "$PUBLIC_HOST" ] || preview=$((preview + 1)) + [ -z "$AUTH_HEADER" ] || preview=$((preview + 1)) + [ -z "$ALLOW_USER" ] || preview=$((preview + 1)) + if [ "$preview" -ne 0 ]; then + [ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none" + case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac + case "$PUBLIC_HOST" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac + case "$AUTH_HEADER" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $AUTH_HEADER" ;; esac + case "$AUTH_HEADER" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $AUTH_HEADER" ;; esac + case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac + flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER" + fi + echo "$flags" +} + +render() { + [ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE" + local node + node="$(command -v node || true)" + [ -n "$node" ] || die "node not found on PATH" + # systemd does not look an ExecStart command up in Environment=PATH, + # so the unit runs node by the path found here, kept as given. + case "$node" in /*) ;; *) die "node path must be absolute: $node" ;; esac + case "$node" in *[!A-Za-z0-9/._+-]*) die "node path contains a character the unit cannot carry: $node" ;; esac + case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac + local node_dir path="/usr/local/bin:/usr/bin:/bin" + node_dir="$(dirname "$node")" + case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac + local flags + flags="$(serve_flags)" || exit 1 + sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE" +} + +install_unit() { + parse_flags "$@" + local unit + unit="$(render)" || exit 1 + mkdir -p "$UNIT_DIR" + local target="$UNIT_DIR/$UNIT_NAME" tmp + tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")" + printf '%s\n' "$unit" > "$tmp" + chmod 0644 "$tmp" + if [ -f "$target" ] && cmp -s "$tmp" "$target"; then + rm -f "$tmp" + echo "unchanged: $target" + else + mv -f "$tmp" "$target" + echo "written: $target" + fi + echo "serve: $(sed -n 's/^ExecStart=//p' "$target")" + if [ "$RELOAD" = 1 ]; then + systemctl --user daemon-reload || die "daemon-reload failed" + echo "daemon-reload done" + fi + cat </dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')" + [ -z "$active" ] || die "refusing: $UNIT_NAME is still running; the human stops it first (systemctl --user stop mosaic-webui)" + fi + if [ -f "$target" ]; then + rm -f "$target" + echo "removed: $target" + else + echo "absent: $target" + fi + if [ "$RELOAD" = 1 ]; then + systemctl --user daemon-reload || die "daemon-reload failed" + echo "daemon-reload done" + fi +} + +status_unit() { + parse_flags "$@" + [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] || usage + local target="$UNIT_DIR/$UNIT_NAME" + echo "unit: $UNIT_NAME" + echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)" + echo " active: $(systemctl --user is-active "$UNIT_NAME" 2>&1 || true)" + systemctl --user show "$UNIT_NAME" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /' || true + if [ ! -f "$target" ]; then + echo "healthz: not checked; no unit at $target" + return 0 + fi + local port + port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")" + [ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; } + local url="http://127.0.0.1:$port/healthz" + echo "healthz: $url $(node -e ' +fetch(process.argv[1], { signal: AbortSignal.timeout(5000) }) + .then(async (res) => console.log(`${res.status} ${(await res.text()).trim()}`)) + .catch((err) => console.log(`unreachable (${err.cause?.code ?? err.name})`)); +' "$url")" +} + +[ $# -ge 1 ] || usage +cmd="$1"; shift +case "$cmd" in + render) + parse_flags "$@" + [ "$DIR_FLAGS" = 0 ] || usage + render ;; + install) install_unit "$@" ;; + uninstall) uninstall_unit "$@" ;; + status) status_unit "$@" ;; + -h|--help) echo "$USAGE" ;; + *) usage ;; +esac