This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

systemd and runit supervisors

Nathan (Apr 20, 2026, 8:19 PM +0200) 429df30a 9d4a75ee

+401 -6
+5
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Supervisor 6 + - Add `pty supervisor systemd install|uninstall` for Linux user services. The installer writes a unit to `~/.config/systemd/user/`, sets `PATH`, `TERM`, `COLORTERM`, and `PTY_SESSION_DIR`, runs `systemctl --user daemon-reload`, and enables/starts the service immediately. If linger is disabled, the command now prints a hint explaining that the service will only stay up while the user session exists and how to enable boot-time startup with `loginctl enable-linger`. 7 + - Add `pty supervisor runit install|uninstall`. The installer writes a `run` script plus symlink-ready service directory (defaulting to `~/.config/runit/{sv,service}`), exporting the same environment variables before execing the supervisor entry point. This is aimed at runit-based systems like Void Linux, while still being easy to test from a private `runsvdir`. 8 + - Add integration coverage for both installers: a real `systemctl --user` install/uninstall test and a runit install test that boots the generated service under a private `runsvdir`. 9 + 5 10 ### TUI framework 6 11 - **Shift+Tab (backtab) now fires as a proper key event.** The raw-stdin parser in `src/tui/input.ts` previously dropped the legacy `ESC[Z` encoding as "unknown CSI" and ignored the shift modifier in the kitty keyboard protocol for code 9 (tab). Both paths now produce `{ name: "backtab", shift: true, ctrl: false, alt: false }`, so form widgets across apps can bind shift-tab for backward field navigation. Added `shift: boolean` to `KeyEvent` as a required field — existing consumers that only read ctrl/alt are unaffected. Surfaced while building the `reminders` app where the form widget needed backtab to move between fields. 7 12
+8 -1
README.md
··· 107 107 pty supervisor status # show supervised sessions 108 108 pty supervisor forget myserver # stop supervising a session 109 109 pty supervisor reset myserver # reset a failed session for retry 110 + pty supervisor launchd install # install launchd auto-start (macOS) 111 + pty supervisor systemd install # install user-level systemd auto-start (Linux) 112 + pty supervisor runit install # install runit service files 110 113 111 114 pty up # start all sessions from ./pty.toml 112 115 pty up ./backend # start sessions from ./backend/pty.toml ··· 204 207 tags = { strategy = "permanent" } 205 208 ``` 206 209 207 - For macOS auto-start on login: `pty supervisor launchd install`. The install will compile a small wrapper binary and prompt you to grant it Full Disk Access (required for sessions on external/removable volumes). Linux (systemd) and other OS integrations are a TODO — contributions welcome. 210 + For auto-start: 211 + 212 + - macOS: `pty supervisor launchd install` — compiles a small wrapper binary and prompts for Full Disk Access (required for sessions on external/removable volumes) 213 + - Linux/systemd: `pty supervisor systemd install` — installs a user service in `~/.config/systemd/user/` and enables it immediately. If you want it to start at boot before login, enable linger with `sudo loginctl enable-linger $USER`. 214 + - runit: `pty supervisor runit install` — writes a `run` script and symlinkable service directory. By default it uses `~/.config/runit/{sv,service}`; on systems like Void you can point it at `/etc/sv` and `/var/service`. 208 215 209 216 ### Plugins 210 217
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.7.0", 3 + "version": "0.9.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "@myobie/pty", 9 - "version": "0.7.0", 9 + "version": "0.9.0", 10 10 "hasInstallScript": true, 11 11 "license": "MIT", 12 12 "dependencies": {
+246 -3
src/cli.ts
··· 105 105 pty supervisor status Show supervised sessions 106 106 pty supervisor forget <name> Stop supervising a session 107 107 pty supervisor reset <name> Reset a failed session for retry 108 + pty supervisor launchd install [--path PATH] Register with macOS launchd 109 + pty supervisor launchd uninstall Remove launchd registration 110 + pty supervisor systemd install [--name NAME] [--path PATH] Register with user systemd 111 + pty supervisor systemd uninstall [--name NAME] Remove user systemd registration 112 + pty supervisor runit install [--name NAME] [--svdir PATH] [--service-dir PATH] [--path PATH] Register with runit 113 + pty supervisor runit uninstall [--name NAME] [--svdir PATH] [--service-dir PATH] Remove runit registration 108 114 pty wrap <command> Auto-wrap a command in pty sessions 109 115 pty unwrap <command> Remove a wrap 110 116 pty wrap --list List wrapped commands ··· 781 787 pty supervisor status Show supervised sessions 782 788 pty supervisor forget <name> Stop supervising a session 783 789 pty supervisor reset <name> Reset a failed session for retry 784 - pty supervisor launchd install [--path PATH] Register with macOS launchd (requires FDA) 785 - pty supervisor launchd uninstall Remove from launchd`); 790 + pty supervisor launchd install [--path PATH] Register with macOS launchd (requires FDA) 791 + pty supervisor launchd uninstall Remove from launchd 792 + pty supervisor systemd install [--name NAME] [--path PATH] Register with user systemd 793 + pty supervisor systemd uninstall [--name NAME] Remove from user systemd 794 + pty supervisor runit install [--name NAME] [--svdir PATH] [--service-dir PATH] [--path PATH] Register with runit 795 + pty supervisor runit uninstall [--name NAME] [--svdir PATH] [--service-dir PATH] Remove from runit`); 786 796 break; 787 797 } 788 798 switch (subCmd) { ··· 816 826 case "launchd": { 817 827 const launchdCmd = args[2]; 818 828 if (launchdCmd === "install") { 819 - // Parse --path flag 820 829 let userPath: string | undefined; 821 830 for (let li = 3; li < args.length; li++) { 822 831 if (args[li] === "--path" && li + 1 < args.length) { ··· 829 838 cmdSupervisorLaunchdUninstall(); 830 839 } else { 831 840 console.error("Usage: pty supervisor launchd install|uninstall"); 841 + process.exit(1); 842 + } 843 + break; 844 + } 845 + case "systemd": { 846 + const systemdCmd = args[2]; 847 + let unitName: string | undefined; 848 + let userPath: string | undefined; 849 + for (let si = 3; si < args.length; si++) { 850 + if (args[si] === "--name" && si + 1 < args.length) { 851 + unitName = args[++si]; 852 + } else if (args[si] === "--path" && si + 1 < args.length) { 853 + userPath = args[++si]; 854 + } 855 + } 856 + if (systemdCmd === "install") { 857 + cmdSupervisorSystemdInstall(unitName, userPath); 858 + } else if (systemdCmd === "uninstall") { 859 + cmdSupervisorSystemdUninstall(unitName); 860 + } else { 861 + console.error("Usage: pty supervisor systemd install|uninstall"); 862 + process.exit(1); 863 + } 864 + break; 865 + } 866 + case "runit": { 867 + const runitCmd = args[2]; 868 + let serviceName: string | undefined; 869 + let svDir: string | undefined; 870 + let serviceDir: string | undefined; 871 + let userPath: string | undefined; 872 + for (let ri = 3; ri < args.length; ri++) { 873 + if (args[ri] === "--name" && ri + 1 < args.length) { 874 + serviceName = args[++ri]; 875 + } else if (args[ri] === "--svdir" && ri + 1 < args.length) { 876 + svDir = args[++ri]; 877 + } else if (args[ri] === "--service-dir" && ri + 1 < args.length) { 878 + serviceDir = args[++ri]; 879 + } else if (args[ri] === "--path" && ri + 1 < args.length) { 880 + userPath = args[++ri]; 881 + } 882 + } 883 + if (runitCmd === "install") { 884 + cmdSupervisorRunitInstall(serviceName, svDir, serviceDir, userPath); 885 + } else if (runitCmd === "uninstall") { 886 + cmdSupervisorRunitUninstall(serviceName, svDir, serviceDir); 887 + } else { 888 + console.error("Usage: pty supervisor runit install|uninstall"); 832 889 process.exit(1); 833 890 } 834 891 break; ··· 1981 2038 } catch {} 1982 2039 1983 2040 console.log(`Reset "${name}". The supervisor will try restarting it.`); 2041 + } 2042 + 2043 + function stopExistingSupervisorIfRunning(): void { 2044 + const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 2045 + try { 2046 + const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 2047 + try { process.kill(pid, "SIGTERM"); } catch {} 2048 + try { fs.unlinkSync(pidPath); } catch {} 2049 + console.log("Stopped existing supervisor."); 2050 + } catch {} 2051 + } 2052 + 2053 + function getSourceRoot(): string { 2054 + return path.join( 2055 + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 2056 + "..", 2057 + ); 2058 + } 2059 + 2060 + function getSupervisorEntryPoint(): string { 2061 + return path.join(getSourceRoot(), "dist", "supervisor-entry.js"); 2062 + } 2063 + 2064 + function ensureSupervisorBuildExists(): void { 2065 + const entryPoint = getSupervisorEntryPoint(); 2066 + if (!fs.existsSync(entryPoint)) { 2067 + console.error(`Missing ${entryPoint}. Run: npm run build`); 2068 + process.exit(1); 2069 + } 2070 + } 2071 + 2072 + function getConfigHome(): string { 2073 + return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); 2074 + } 2075 + 2076 + function shellQuote(value: string): string { 2077 + return `'${value.replace(/'/g, `'"'"'`)}'`; 2078 + } 2079 + 2080 + function systemdQuote(value: string): string { 2081 + return `"${value.replace(/["\\$`]/g, "\\$&")}"`; 2082 + } 2083 + 2084 + function normalizeServiceName(name: string | undefined, suffix: string): string { 2085 + const raw = (name && name.trim()) ? name.trim() : `pty-supervisor${suffix}`; 2086 + return raw.endsWith(suffix) ? raw : `${raw}${suffix}`; 2087 + } 2088 + 2089 + function runChecked(command: string, args: string[], options: Parameters<typeof spawnSync>[2] = {}): ReturnType<typeof spawnSync> { 2090 + const result = spawnSync(command, args, { encoding: "utf-8", ...options }); 2091 + if (result.status !== 0) { 2092 + const stderr = String(result.stderr ?? "").trim(); 2093 + const stdout = String(result.stdout ?? "").trim(); 2094 + console.error(`Failed: ${command} ${args.join(" ")}`); 2095 + if (stderr) console.error(stderr); 2096 + else if (stdout) console.error(stdout); 2097 + process.exit(result.status ?? 1); 2098 + } 2099 + return result; 2100 + } 2101 + 2102 + function maybePrintSystemdLingerHint(): void { 2103 + const user = os.userInfo().username; 2104 + const result = spawnSync("loginctl", ["show-user", user, "-p", "Linger", "--value"], { 2105 + encoding: "utf-8", 2106 + }); 2107 + if (result.status === 0 && result.stdout.trim().toLowerCase() !== "yes") { 2108 + console.log(""); 2109 + console.log(`Note: loginctl linger is disabled for ${user}.`); 2110 + console.log("This user service will start while you're logged in, but not at boot."); 2111 + console.log(`To keep it running across reboots, run: sudo loginctl enable-linger ${user}`); 2112 + } 2113 + } 2114 + 2115 + function cmdSupervisorSystemdInstall(unitName?: string, userPath?: string): void { 2116 + ensureSupervisorBuildExists(); 2117 + stopExistingSupervisorIfRunning(); 2118 + 2119 + const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 2120 + const entryPoint = getSupervisorEntryPoint(); 2121 + const resolvedUnit = normalizeServiceName(unitName, ".service"); 2122 + const unitDir = path.join(getConfigHome(), "systemd", "user"); 2123 + const unitPath = path.join(unitDir, resolvedUnit); 2124 + const sessionDir = getSessionDir(); 2125 + 2126 + fs.mkdirSync(unitDir, { recursive: true }); 2127 + fs.writeFileSync(unitPath, `[Unit] 2128 + Description=pty supervisor 2129 + 2130 + [Service] 2131 + Type=simple 2132 + Environment=${systemdQuote(`PATH=${envPath}`)} 2133 + Environment=${systemdQuote(`TERM=xterm-256color`)} 2134 + Environment=${systemdQuote(`COLORTERM=truecolor`)} 2135 + Environment=${systemdQuote(`PTY_SESSION_DIR=${sessionDir}`)} 2136 + WorkingDirectory=${os.homedir()} 2137 + ExecStart=${process.execPath} ${entryPoint} 2138 + Restart=always 2139 + RestartSec=5 2140 + 2141 + [Install] 2142 + WantedBy=default.target 2143 + `); 2144 + 2145 + runChecked("systemctl", ["--user", "daemon-reload"]); 2146 + runChecked("systemctl", ["--user", "enable", "--now", resolvedUnit]); 2147 + 2148 + console.log(`Installed systemd user unit: ${unitPath}`); 2149 + console.log(`Manage it with: systemctl --user status ${resolvedUnit}`); 2150 + maybePrintSystemdLingerHint(); 2151 + } 2152 + 2153 + function cmdSupervisorSystemdUninstall(unitName?: string): void { 2154 + const resolvedUnit = normalizeServiceName(unitName, ".service"); 2155 + const unitPath = path.join(getConfigHome(), "systemd", "user", resolvedUnit); 2156 + 2157 + spawnSync("systemctl", ["--user", "disable", "--now", resolvedUnit], { encoding: "utf-8" }); 2158 + try { fs.unlinkSync(unitPath); } catch {} 2159 + runChecked("systemctl", ["--user", "daemon-reload"]); 2160 + spawnSync("systemctl", ["--user", "reset-failed", resolvedUnit], { encoding: "utf-8" }); 2161 + stopExistingSupervisorIfRunning(); 2162 + 2163 + console.log(`Removed systemd user unit: ${unitPath}`); 2164 + } 2165 + 2166 + function cmdSupervisorRunitInstall( 2167 + serviceName?: string, 2168 + svDir?: string, 2169 + serviceDir?: string, 2170 + userPath?: string, 2171 + ): void { 2172 + ensureSupervisorBuildExists(); 2173 + stopExistingSupervisorIfRunning(); 2174 + 2175 + const resolvedName = normalizeServiceName(serviceName, ""); 2176 + const resolvedSvDir = path.resolve(svDir ?? path.join(getConfigHome(), "runit", "sv")); 2177 + const resolvedServiceDir = path.resolve(serviceDir ?? path.join(getConfigHome(), "runit", "service")); 2178 + const servicePath = path.join(resolvedSvDir, resolvedName); 2179 + const runPath = path.join(servicePath, "run"); 2180 + const linkPath = path.join(resolvedServiceDir, resolvedName); 2181 + const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 2182 + const sessionDir = getSessionDir(); 2183 + const entryPoint = getSupervisorEntryPoint(); 2184 + 2185 + fs.mkdirSync(resolvedSvDir, { recursive: true }); 2186 + fs.mkdirSync(resolvedServiceDir, { recursive: true }); 2187 + fs.rmSync(servicePath, { recursive: true, force: true }); 2188 + fs.mkdirSync(servicePath, { recursive: true, mode: 0o755 }); 2189 + 2190 + fs.writeFileSync(runPath, `#!/bin/sh 2191 + export PATH=${shellQuote(envPath)} 2192 + export TERM=${shellQuote("xterm-256color")} 2193 + export COLORTERM=${shellQuote("truecolor")} 2194 + export PTY_SESSION_DIR=${shellQuote(sessionDir)} 2195 + exec ${shellQuote(process.execPath)} ${shellQuote(entryPoint)} 2196 + `); 2197 + fs.chmodSync(runPath, 0o755); 2198 + 2199 + try { 2200 + const stat = fs.lstatSync(linkPath); 2201 + if (stat.isSymbolicLink() || stat.isFile()) fs.unlinkSync(linkPath); 2202 + else { 2203 + console.error(`Refusing to replace non-symlink path: ${linkPath}`); 2204 + process.exit(1); 2205 + } 2206 + } catch {} 2207 + fs.symlinkSync(servicePath, linkPath); 2208 + 2209 + console.log(`Installed runit service: ${servicePath}`); 2210 + console.log(`Enabled via symlink: ${linkPath}`); 2211 + console.log(`Start it with: runsvdir ${shellQuote(resolvedServiceDir)}`); 2212 + } 2213 + 2214 + function cmdSupervisorRunitUninstall(serviceName?: string, svDir?: string, serviceDir?: string): void { 2215 + const resolvedName = normalizeServiceName(serviceName, ""); 2216 + const resolvedSvDir = path.resolve(svDir ?? path.join(getConfigHome(), "runit", "sv")); 2217 + const resolvedServiceDir = path.resolve(serviceDir ?? path.join(getConfigHome(), "runit", "service")); 2218 + const servicePath = path.join(resolvedSvDir, resolvedName); 2219 + const linkPath = path.join(resolvedServiceDir, resolvedName); 2220 + 2221 + try { fs.unlinkSync(linkPath); } catch {} 2222 + try { fs.rmSync(servicePath, { recursive: true, force: true }); } catch {} 2223 + stopExistingSupervisorIfRunning(); 2224 + 2225 + console.log(`Removed runit service: ${servicePath}`); 2226 + console.log(`Removed symlink: ${linkPath}`); 1984 2227 } 1985 2228 1986 2229 async function cmdSupervisorLaunchdInstall(userPath?: string): Promise<void> {
+140
tests/supervisor-service-install.test.ts
··· 1 + import { describe, it, expect, afterEach } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + 12 + const bgPids: number[] = []; 13 + const cleanupUnits: string[] = []; 14 + const cleanupPaths: string[] = []; 15 + 16 + function runCli(env: NodeJS.ProcessEnv, ...args: string[]) { 17 + return spawnSync(nodeBin, [cliPath, ...args], { 18 + env, 19 + encoding: "utf-8", 20 + timeout: 20000, 21 + }); 22 + } 23 + 24 + function waitForFile(filePath: string, timeoutMs = 10000): Promise<void> { 25 + const start = Date.now(); 26 + return new Promise((resolve, reject) => { 27 + function check() { 28 + if (fs.existsSync(filePath)) return resolve(); 29 + if (Date.now() - start > timeoutMs) return reject(new Error(`Timeout waiting for ${filePath}`)); 30 + setTimeout(check, 100); 31 + } 32 + check(); 33 + }); 34 + } 35 + 36 + function killPid(pid: number | undefined) { 37 + if (!pid) return; 38 + try { process.kill(pid, "SIGTERM"); } catch {} 39 + } 40 + 41 + afterEach(() => { 42 + for (const unit of cleanupUnits.splice(0)) { 43 + spawnSync("systemctl", ["--user", "disable", "--now", unit], { encoding: "utf-8" }); 44 + spawnSync("systemctl", ["--user", "reset-failed", unit], { encoding: "utf-8" }); 45 + const unitPath = path.join(os.homedir(), ".config", "systemd", "user", unit); 46 + try { fs.unlinkSync(unitPath); } catch {} 47 + } 48 + if (cleanupUnits.length > 0) { 49 + spawnSync("systemctl", ["--user", "daemon-reload"], { encoding: "utf-8" }); 50 + } else { 51 + spawnSync("systemctl", ["--user", "daemon-reload"], { encoding: "utf-8" }); 52 + } 53 + 54 + for (const pid of bgPids.splice(0)) killPid(pid); 55 + for (const p of cleanupPaths.splice(0)) { 56 + try { fs.rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {} 57 + } 58 + }); 59 + 60 + describe("supervisor service installers", () => { 61 + it("installs and uninstalls a user systemd service", async () => { 62 + const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-systemd-")); 63 + cleanupPaths.push(sessionDir); 64 + 65 + const unitBase = `pty-supervisor-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 66 + const unitFile = `${unitBase}.service`; 67 + cleanupUnits.push(unitFile); 68 + 69 + const env = { ...process.env, PTY_SESSION_DIR: sessionDir }; 70 + const install = runCli(env, "supervisor", "systemd", "install", "--name", unitBase); 71 + expect(install.status).toBe(0); 72 + expect(install.stdout).toContain(unitFile); 73 + 74 + await waitForFile(path.join(sessionDir, "supervisor", "supervisor.pid")); 75 + 76 + const active = spawnSync("systemctl", ["--user", "is-active", unitFile], { 77 + encoding: "utf-8", 78 + timeout: 10000, 79 + }); 80 + expect(active.status).toBe(0); 81 + expect(active.stdout.trim()).toBe("active"); 82 + 83 + const uninstall = runCli(env, "supervisor", "systemd", "uninstall", "--name", unitBase); 84 + expect(uninstall.status).toBe(0); 85 + 86 + const activeAfter = spawnSync("systemctl", ["--user", "is-active", unitFile], { 87 + encoding: "utf-8", 88 + timeout: 10000, 89 + }); 90 + expect(activeAfter.status).not.toBe(0); 91 + }, 30000); 92 + 93 + it("installs a runit service that can be started by a private runsvdir", async () => { 94 + if (spawnSync("sh", ["-lc", "command -v runsvdir >/dev/null 2>&1"]).status !== 0) { 95 + throw new Error("runsvdir is not installed"); 96 + } 97 + 98 + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-runit-")); 99 + const sessionDir = path.join(root, "sessions"); 100 + const svDir = path.join(root, "sv"); 101 + const serviceDir = path.join(root, "service"); 102 + fs.mkdirSync(sessionDir, { recursive: true }); 103 + cleanupPaths.push(root); 104 + 105 + const serviceName = `pty-supervisor-test-${Math.random().toString(36).slice(2, 8)}`; 106 + const env = { ...process.env, PTY_SESSION_DIR: sessionDir }; 107 + 108 + const install = runCli( 109 + env, 110 + "supervisor", "runit", "install", 111 + "--name", serviceName, 112 + "--svdir", svDir, 113 + "--service-dir", serviceDir, 114 + ); 115 + expect(install.status).toBe(0); 116 + expect(fs.existsSync(path.join(svDir, serviceName, "run"))).toBe(true); 117 + expect(fs.lstatSync(path.join(serviceDir, serviceName)).isSymbolicLink()).toBe(true); 118 + 119 + const child = spawn("runsvdir", [serviceDir], { 120 + detached: true, 121 + stdio: ["ignore", "ignore", "ignore"], 122 + env, 123 + }); 124 + child.unref(); 125 + bgPids.push(child.pid!); 126 + 127 + await waitForFile(path.join(sessionDir, "supervisor", "supervisor.pid")); 128 + 129 + const uninstall = runCli( 130 + env, 131 + "supervisor", "runit", "uninstall", 132 + "--name", serviceName, 133 + "--svdir", svDir, 134 + "--service-dir", serviceDir, 135 + ); 136 + expect(uninstall.status).toBe(0); 137 + expect(fs.existsSync(path.join(svDir, serviceName))).toBe(false); 138 + expect(fs.existsSync(path.join(serviceDir, serviceName))).toBe(false); 139 + }, 30000); 140 + });