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