`lembas remote list | add | remove | enable | disable | mode | terminal`, with
`--json` printing {enabled, roots, max_mode, terminal}, for LLeMbas Desktop.
Writes go through the global config writer and keep comments.
The device's limits are now live getters over the remote: block, re-read when
config.yaml's stat changes. `lembas service` links even with remote work off
or no roots: it says roots: [] and refuses every session, directory and
terminal, so the instance can tell "no folder allowed yet" from "not running".
When what the instance was told changes, the link re-dials once nothing is
working, so `lembas remote add` or `enable` takes effect without a restart.
Harness spec 1.0.1 documents both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
9.6 KiB
TypeScript
235 lines
9.6 KiB
TypeScript
// `lembas service`: the link as a background service, after Hermes's gateway — a
|
|
// systemd *user* unit, restarted when it fails, its output in the journal, one copy at a time.
|
|
//
|
|
// lembas service install write ~/.config/systemd/user/lembas.service and start it
|
|
// lembas service uninstall stop it and remove the unit
|
|
// lembas service status what the running link says (and systemd's view)
|
|
// lembas service logs its journal
|
|
// lembas service run what the unit runs: the link, in the foreground
|
|
//
|
|
// It runs as the user, never as root, and listens on nothing. To keep it running while nobody is
|
|
// logged in, the user needs lingering (`loginctl enable-linger`), which `install` says rather than
|
|
// does: on most systems that is a decision for whoever administers the machine.
|
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
import { homedir } from "node:os"
|
|
import { join } from "node:path"
|
|
import { paths } from "./config/paths.ts"
|
|
import { LinkError, limitsFromConfig, runLink, type LinkStatus } from "./acp/link.ts"
|
|
import { Hub } from "./acp/hub.ts"
|
|
|
|
export const UNIT = "lembas.service"
|
|
const unitDir = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user")
|
|
export const unitFile = () => join(unitDir(), UNIT)
|
|
const statusFile = () => join(paths.state, "service.json")
|
|
const lockFile = () => join(paths.state, "service.lock")
|
|
|
|
/** The command the unit starts: this binary, or `bun <cli>` when run from source. */
|
|
export function execStart(): string {
|
|
const exe = process.execPath
|
|
const script = process.argv[1] ?? ""
|
|
const fromSource = /(^|\/)bun$/.test(exe) && script.endsWith(".ts")
|
|
const q = (s: string) => (/[\s"'\\]/.test(s) ? `"${s.replace(/(["\\])/g, "\\$1")}"` : s)
|
|
return fromSource ? `${q(exe)} ${q(script)} service run` : `${q(exe)} service run`
|
|
}
|
|
|
|
export function unitText(command = execStart()): string {
|
|
return `[Unit]
|
|
Description=LLeMbas CLI: work from a LLeMbas instance (lembas service)
|
|
Documentation=https://llembas.eu
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=${command}
|
|
Restart=on-failure
|
|
RestartSec=10
|
|
# Stopping: the service ends its own work in seconds; a shell left behind (a terminal
|
|
# opened from the web UI is an interactive bash, which ignores SIGTERM) is hung up on, and nothing
|
|
# holds a stop for the default 90 s.
|
|
KillMode=mixed
|
|
SendSIGHUP=yes
|
|
TimeoutStopSec=15
|
|
# Nothing here needs more than the user already has.
|
|
NoNewPrivileges=yes
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
`
|
|
}
|
|
|
|
export type Systemctl = (...args: string[]) => { ok: boolean; out: string }
|
|
|
|
function systemctl(...args: string[]): { ok: boolean; out: string } {
|
|
const r = Bun.spawnSync(["systemctl", "--user", ...args], { stdout: "pipe", stderr: "pipe" })
|
|
return { ok: r.exitCode === 0, out: (r.stdout.toString() + r.stderr.toString()).trim() }
|
|
}
|
|
|
|
/** The installed unit, if any: what `service uninstall` and `lembas uninstall` stop. */
|
|
export const installedUnits = () => [unitFile()].filter((f) => existsSync(f))
|
|
|
|
/** Stop, disable and remove the unit, where installed. Before the binary goes too: a unit left
|
|
* running sees its binary vanish and restart-loops on 203/EXEC every 10 s. A systemctl
|
|
* that fails (no user manager — a container, an SSH session without lingering) does not stop the
|
|
* files going: nothing is left to start it again. */
|
|
export function removeUnits(run: Systemctl = systemctl): string[] {
|
|
const lines: string[] = []
|
|
for (const file of installedUnits()) {
|
|
run("disable", "--now", UNIT)
|
|
rmSync(file, { force: true })
|
|
lines.push(`stopped, and removed ${file}`)
|
|
}
|
|
if (lines.length) run("daemon-reload")
|
|
return lines
|
|
}
|
|
|
|
export function install(): string[] {
|
|
mkdirSync(unitDir(), { recursive: true })
|
|
const lines: string[] = []
|
|
writeFileSync(unitFile(), unitText())
|
|
lines.push(`wrote ${unitFile()}`)
|
|
const reload = systemctl("daemon-reload")
|
|
if (!reload.ok) return [...lines, `systemctl --user daemon-reload failed: ${reload.out}`, "Start it by hand: lembas service run"]
|
|
const start = systemctl("enable", "--now", UNIT)
|
|
lines.push(start.ok ? "enabled and started" : `could not start it: ${start.out}`)
|
|
const user = process.env.USER ?? ""
|
|
const linger = Bun.spawnSync(["loginctl", "show-user", user, "--property=Linger"], { stdout: "pipe", stderr: "pipe" }).stdout.toString()
|
|
if (!/Linger=yes/.test(linger)) lines.push(`It stops when you log out. To keep it running: loginctl enable-linger ${user}`)
|
|
return lines
|
|
}
|
|
|
|
export function uninstall(): string[] {
|
|
const lines = removeUnits()
|
|
if (!lines.some((l) => l.includes(unitFile()))) lines.unshift("no service installed")
|
|
rmSync(statusFile(), { force: true })
|
|
return lines
|
|
}
|
|
|
|
export function status(): string[] {
|
|
const lines: string[] = []
|
|
try {
|
|
const s = JSON.parse(readFileSync(statusFile(), "utf8")) as LinkStatus & { pid: number }
|
|
let alive = false
|
|
try {
|
|
process.kill(s.pid, 0)
|
|
alive = true
|
|
} catch {}
|
|
lines.push(alive ? `${s.state} — ${s.instance} since ${s.since}${s.detail ? ` (${s.detail})` : ""}` : `not running (last: ${s.state}${s.detail ? `, ${s.detail}` : ""})`)
|
|
} catch {
|
|
lines.push("not running")
|
|
}
|
|
if (existsSync(unitFile())) lines.push(`systemd: ${systemctl("is-active", UNIT).out || "unknown"}`)
|
|
else lines.push("no systemd unit installed (lembas service install)")
|
|
return lines
|
|
}
|
|
|
|
export function logs(follow: boolean): number {
|
|
const r = Bun.spawnSync(["journalctl", "--user", "-u", UNIT, "-n", "200", ...(follow ? ["-f"] : [])], { stdout: "inherit", stderr: "inherit" })
|
|
return r.exitCode ?? 1
|
|
}
|
|
|
|
/** One copy at a time: a second `service run` refuses while the first holds the lock. */
|
|
function takeLock(): () => void {
|
|
mkdirSync(paths.state, { recursive: true })
|
|
try {
|
|
const held = Number(readFileSync(lockFile(), "utf8"))
|
|
if (held) {
|
|
process.kill(held, 0)
|
|
throw new LinkError(`already running (pid ${held})`)
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof LinkError) throw e
|
|
}
|
|
const fd = openSync(lockFile(), "w")
|
|
writeFileSync(fd, String(process.pid))
|
|
closeSync(fd)
|
|
return () => rmSync(lockFile(), { force: true })
|
|
}
|
|
|
|
/** An installed unit from an older release is brought up to this one's text, so a change to
|
|
* how it stops reaches machines that installed it before. It takes effect at the next start. */
|
|
function refreshUnit() {
|
|
try {
|
|
if (!existsSync(unitFile())) return
|
|
const want = unitText()
|
|
if (readFileSync(unitFile(), "utf8") === want) return
|
|
writeFileSync(unitFile(), want)
|
|
systemctl("daemon-reload")
|
|
console.log(`brought ${unitFile()} up to this release`)
|
|
} catch {}
|
|
}
|
|
|
|
/** Exit status for "the binary was updated; start me again" — non-zero, so Restart=on-failure does. */
|
|
export const RESTART_FOR_UPDATE = 75
|
|
|
|
export async function run(instance?: string): Promise<number> {
|
|
const release = takeLock()
|
|
const stop = new AbortController()
|
|
for (const sig of ["SIGTERM", "SIGINT"] as const) process.once(sig, () => stop.abort())
|
|
refreshUnit()
|
|
// The hub: the terminals on this machine share their sessions through the service. A
|
|
// terminal that holds the link itself for /remote keeps it until it closes; one link per device,
|
|
// so the service waits for it rather than knocking it off.
|
|
// The limits are live: `lembas remote …` applies to this process within seconds (link.ts).
|
|
const hub = new Hub({ service: true, limits: limitsFromConfig() })
|
|
let said = false
|
|
while (!stop.signal.aborted && !(await hub.listen())) {
|
|
if (!said) console.log("a terminal here holds the link (/remote); waiting for it to close")
|
|
said = true
|
|
await new Promise<void>((r) => {
|
|
const t = setTimeout(r, 15_000)
|
|
stop.signal.addEventListener("abort", () => (clearTimeout(t), r()), { once: true })
|
|
})
|
|
}
|
|
// Updated on disk: a terminal installs a new release, and this process goes on running
|
|
// the old one. It starts again on the new one only when nothing works here — no session
|
|
// working, none a terminal shares in the middle of a turn — so an update never cuts a reply off.
|
|
// Sessions are in the store; the terminals reconnect by themselves.
|
|
let updated = false
|
|
const binary = process.execPath
|
|
const fromSource = /(^|\/)bun$/.test(binary)
|
|
const builtAt = fromSource ? 0 : mtime(binary)
|
|
const watch = fromSource
|
|
? undefined
|
|
: setInterval(() => {
|
|
if (!builtAt || mtime(binary) === builtAt || hub.busy()) return
|
|
updated = true
|
|
console.log("a new release is installed and nothing is working: starting again on it")
|
|
stop.abort()
|
|
}, 30_000)
|
|
watch?.unref?.()
|
|
try {
|
|
// Remote work off or no roots: linked all the same, telling the instance `roots: []` and
|
|
// refusing every session; `lembas remote add <dir>` or `enable` makes it serve, no restart.
|
|
const end = await runLink({
|
|
instance,
|
|
hub,
|
|
signal: stop.signal,
|
|
onStatus: (s) => {
|
|
writeFileSync(statusFile(), JSON.stringify({ ...s, pid: process.pid }, null, 2))
|
|
console.log(`${s.state}${s.detail ? `: ${s.detail}` : ""}`)
|
|
},
|
|
})
|
|
// Refused (token revoked): exit cleanly so systemd does not knock on a closed door for ever.
|
|
void end
|
|
return updated ? RESTART_FOR_UPDATE : 0
|
|
} catch (e) {
|
|
console.error(`lembas service: ${(e as Error).message}`)
|
|
// A configuration that cannot work (not logged in) is not fixed by restarting: exit 0 so
|
|
// Restart=on-failure leaves it, and the journal says why.
|
|
return e instanceof LinkError ? 0 : 1
|
|
} finally {
|
|
clearInterval(watch)
|
|
hub.close()
|
|
release()
|
|
}
|
|
}
|
|
|
|
function mtime(file: string): number {
|
|
try {
|
|
return statSync(file).mtimeMs
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|