Files
stack/agents/rocko/work/queue-75/build.patch
T
jason.woltjeandClaude Opus 5.5 bce20fcbbe review(rocko): row 75 round 1 packet, the Console as a user service (#1569)
Committed by sage for rocko (no credential). Candidate manifest 7dc571db, packet manifest 0bc9a870, base 43d2328f.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:33:54 -05:00

524 lines
25 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 9a28a090..631e1814 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 00000000..081bd963
--- /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 00000000..d50b6652
--- /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 00000000..4ff06833
--- /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 <<MSG
+this script starts nothing: the Console holds the human's capability, so the
+human starts it from their own shell:
+ systemctl --user enable --now mosaic-webui start now and at login
+ systemctl --user status mosaic-webui
+ journalctl --user -u mosaic-webui -f the Console's log
+ systemctl --user stop mosaic-webui SIGTERM; restartable
+ scripts/webui-service.sh status unit state and /healthz
+survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
+MSG
+}
+
+uninstall_unit() {
+ parse_flags "$@"
+ [ "$SERVE_SET" = 0 ] || usage
+ local target="$UNIT_DIR/$UNIT_NAME"
+ if [ "$RELOAD" = 1 ]; then
+ local active
+ active="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/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