// SPDX-License-Identifier: GPL-3.0-or-later /** * One-command scheduling: --install-timer / --uninstall-timer. * * Writes USER-level systemd units (~/.config/systemd/user/, no sudo) so the * timer belongs to the logged-in user and its checks can see the graphical * session — which is what --notify needs. The units are generated with * resolved absolute paths (process.execPath + this package's bin/doctor.js), * not copied from packaging/, so they survive npm relocation and work for * npx-style installs too. * * ExecStart carries --notify: the timer is a watchdog, not a report mill — * the machine speaks only when something NEW breaks (see docs/cli.md). * Writing is atomic (temp file + rename) so a crash never leaves a half * unit behind, mirroring history.js. */ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { atomicWrite } from "./fsx.js"; import { homedir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; const MARKER = "Generated by linux-doctor"; /** Where the user units live (override honored at call time, for tests). */ export function unitPaths(home = homedir()) { const dir = process.env.LINUX_DOCTOR_USER_UNITS || join(home, ".config", "systemd", "user"); return { dir, service: join(dir, "linux-doctor.service"), timer: join(dir, "linux-doctor.timer") }; } /** Standard "is systemd the init" probe, injectable for tests. */ export function systemdPresent(exists = existsSync) { return exists("/run/systemd/system"); } /** Resolve this package's CLI entry, even when installed via npm. */ export function cliBin() { return fileURLToPath(new URL("../bin/doctor.js", import.meta.url)); } function q(p) { return `"${String(p).replace(/"/g, '\\"')}"`; } /** The oneshot service: run the CLI with --notify. Pure, for tests. */ export function renderService({ node, bin, extraArgs = [] }) { const args = extraArgs.length > 0 ? ` ${extraArgs.join(" ")}` : ""; return [ "[Unit]", "Description=Linux Doctor — scheduled system health check", "Documentation=https://github.com/7sh1d0w7x/linux-doctor", "", "[Service]", "Type=oneshot", `# ${MARKER} — safe to remove with: linux-doctor --uninstall-timer`, `ExecStart=${q(node)} ${q(bin)} --notify${args}`, // --notify exits 1 when it has high/medium findings (docs/compatibility.md). // For a watchdog that is the success case: without this line systemd marks // the unit failed every time the machine has anything worth reporting. "SuccessExitStatus=1", "SyslogIdentifier=linux-doctor", "", "", ].join("\n"); } /** The timer: shortly after boot, then once a day; catch up on missed runs. */ export function renderTimer({ hours = 24 } = {}) { return [ "[Unit]", "Description=Run Linux Doctor daily", "", "[Timer]", "OnBootSec=10min", `OnUnitActiveSec=${hours}h`, "Persistent=true", "", "[Install]", "WantedBy=timers.target", "", "", ].join("\n"); } function writeUnit(file, content) { atomicWrite(file, content, { mode: 0o644, dirMode: 0o755 }); } /** * Install and enable the user timer. Injectable `systemctl`/`run`/`exists` * keep the happy path testable without touching a real session. */ export function installTimer({ node = process.execPath, bin = cliBin(), systemctl = "systemctl", exists = existsSync, run = execFileSync, } = {}) { if (!systemdPresent(exists)) { return { ok: false, error: "systemd is not running (no /run/systemd/system) — --install-timer needs a systemd session" }; } const paths = unitPaths(); mkdirSync(paths.dir, { recursive: true }); writeUnit(paths.service, renderService({ node, bin })); writeUnit(paths.timer, renderTimer({})); run(systemctl, ["--user", "daemon-reload"]); run(systemctl, ["--user", "enable", "--now", "linux-doctor.timer"]); return { ok: true, paths, message: `✓ Timer enabled — Linux Doctor runs shortly after boot, then daily, and notifies you only when something NEW appears.\n Units: ${paths.service}\n ${paths.timer}\n Remove any time with: linux-doctor --uninstall-timer`, }; } /** * Read-only status of the user timer for the dashboard's schedule strip. * Never throws, never writes: a missing systemctl, a non-zero exit, or a * non-systemd host simply reads as "not enabled/active". Injectable * `exists`/`exec` keep it testable without touching a real session. */ export function timerStatus({ exists = existsSync, exec = execFileSync } = {}) { const paths = unitPaths(); const installed = !!(exists(paths.service) && exists(paths.timer)); const out = { installed, enabled: false, active: false, systemd: systemdPresent(exists) }; if (!installed || !out.systemd) return out; try { out.enabled = String(exec("systemctl", ["--user", "is-enabled", "linux-doctor.timer"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim() === "enabled"; } catch { out.enabled = false; } try { out.active = String(exec("systemctl", ["--user", "is-active", "linux-doctor.timer"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim() === "active"; } catch { out.active = false; } return out; } /** * Remove and disable the user timer. Every step is forgiving: a half-installed * or already-removed timer must still uninstall cleanly. */ export function uninstallTimer({ systemctl = "systemctl", exists = existsSync, run = execFileSync, rm = rmSync, } = {}) { const paths = unitPaths(); let touched = false; try { run(systemctl, ["--user", "disable", "--now", "linux-doctor.timer"]); touched = true; } catch { // never installed, or systemctl missing — removing files is enough } for (const f of [paths.service, paths.timer]) { if (exists(f)) { rm(f); touched = true; } } try { run(systemctl, ["--user", "daemon-reload"]); } catch { // best effort — the units are gone either way } return { ok: true, message: touched ? "✓ Timer removed and disabled." : "No user timer was installed — nothing to remove." }; }