npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@push.rocks/smartdaemon

v3.0.0

Published

Start scripts as long running daemons and manage them.

Readme

@push.rocks/smartdaemon 🚀

Turn your Node.js scripts into production-ready system daemons

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

npm version License: MIT

Seamlessly manage long-running processes, background services, and system daemons with smart root detection, automatic privilege escalation, and systemd integration.

🎯 What is SmartDaemon?

@push.rocks/smartdaemon is your Swiss Army knife for turning Node.js applications into bulletproof system services. Whether you're deploying microservices, running scheduled tasks, or managing background workers, SmartDaemon handles the complex stuff so you can focus on your business logic.

✨ Key Features

  • 🔐 Smart Privilege Management: Automatically detects root status and handles sudo operations seamlessly
  • 👤 User/Group Control: Run services under specific users for enhanced security
  • 🔄 Systemd Integration: Full Linux systemd support for production-grade service management
  • 🛡️ Auto-Recovery: Built-in service restart on failure
  • 📝 Declarative Service Definitions: Simple, clear service configuration
  • 🔌 Passwordless or Password-Based Sudo: Works with passwordless sudo, or hands a configured sudo password to sudo on standard input (no terminal or PTY needed)
  • 🎛️ Complete Lifecycle Management: Start, stop, enable, disable, and reload services with ease

📦 Installation

# Using npm
npm install @push.rocks/smartdaemon --save

# Using pnpm (recommended)
pnpm add @push.rocks/smartdaemon

# Using yarn
yarn add @push.rocks/smartdaemon

🚀 Quick Start

Basic Usage

import { SmartDaemon } from '@push.rocks/smartdaemon';

// Initialize SmartDaemon
const daemon = new SmartDaemon();

// Create and enable a service
const myService = await daemon.addService({
  name: 'my-api-server',
  description: 'My awesome API server',
  command: 'node server.js',
  workingDir: '/opt/myapp',
  version: '1.0.0'
});

// Enable and start the service
await myService.enable(); // Creates systemd service file
await myService.start();  // Starts the service

Running Services as Specific Users 👥

Security best practice: Never run services as root unless absolutely necessary!

const webService = await daemon.addService({
  name: 'web-server',
  description: 'Production web server',
  command: 'node app.js',
  workingDir: '/var/www/app',
  version: '2.1.0',
  user: 'www-data',    // Run as www-data user
  group: 'www-data'    // Run under www-data group
});

await webService.enable();
await webService.start();

🔐 Smart Privilege Escalation

SmartDaemon intelligently handles privilege escalation based on your execution context:

Automatic Root Detection

// SmartDaemon automatically detects if running as root
const daemon = new SmartDaemon();

// If running as regular user, sudo will be used automatically
// If running as root, commands execute directly

With Sudo Password

If you're not running as root and don't have passwordless sudo configured:

const daemon = new SmartDaemon({
  sudoPassword: 'your-sudo-password'  // Will be used for privilege escalation
});

// All privileged operations will now use the provided password
const service = await daemon.addService({
  name: 'privileged-service',
  description: 'Service requiring root privileges',
  command: 'node admin-task.js',
  workingDir: '/opt/admin',
  version: '1.0.0'
});

await service.enable(); // Automatically uses sudo with password

Each privileged command runs as sudo -k -S -p '' -- <command>: sudo reads the password from standard input, which is closed right after the password line, so a rejected password fails at once instead of waiting for a retry. The password is never placed in argv, the environment or the output, and no PTY (node-pty) is involved. -k makes sudo ask for the password every time and leaves no cached credentials behind. If a sudoers rule lets the command run without a password (NOPASSWD), sudo does not read standard input and the password line reaches the command's standard input instead (systemctl, mv, chmod and rm never read it); leave sudoPassword unset when sudo is passwordless.

A command that does not exit 0 rejects with SmartDaemonCommandError (command, exitCode, signal, stderr), with or without a sudo password: a rejected password, a missing sudo (exit code 127) or a failing systemctl call all surface this way. Besides systemctl, saving and deleting a unit file as a non-root user runs mv, chmod and rm through sudo.

Passwordless Sudo (Recommended for Production)

For production environments, configure passwordless sudo for specific systemctl commands:

  1. Create a sudoers file: /etc/sudoers.d/smartdaemon
  2. Add the following content:
# Allow smartdaemon to manage services without password
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl start smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload

📚 Complete API

SmartDaemon Class

interface ISmartDaemonOptions {
  sudoPassword?: string;  // Optional sudo password for privilege escalation
}

class SmartDaemon {
  constructor(options?: ISmartDaemonOptions);
  
  // Add a new service or update existing one
  async addService(options: ISmartDaemonServiceOptions): Promise<SmartDaemonService>;
  
  // Direct access to managers (advanced usage)
  systemdManager: SmartDaemonSystemdManager;
  templateManager: SmartDaemonTemplateManager;
}

Inspecting and controlling an existing systemd unit

SystemdUnit works with an exact canonical .service name. Construction performs no I/O. It does not create or rewrite a unit, enable it, remove a mask, or invoke sudo. The caller must already have permission for lifecycle commands.

import { SystemdUnit } from '@push.rocks/smartdaemon';

const unit = new SystemdUnit({ unitName: 'smartdaemon_my-api-server.service' });
const state = await unit.inspect();
console.log(state.loadState, state.activeState, state.unitFileState);

// Address the calling user's manager when running as that user.
const userUnit = new SystemdUnit({ unitName: 'authswitch.service', scope: 'user' });
const userState = await userUnit.inspect();

// Application code decides whether stopping or restarting is appropriate.
// await unit.stop();
// await unit.start();

Snapshots are immutable and also include subState, mainPid, controlPid, controlGroup, killMode, job, statusText, result and fileDescriptorStoreCount. An empty job means no systemd job was reported; otherwise it is the pending job's id. statusText is the service's last STATUS= notification, verbatim (empty when it sent none), and result is systemd's Result: success, or why the service last failed (timeout, exit-code, signal, core-dump, watchdog, start-limit-hit, oom-kill, resources, protocol, exec-condition; a newer manager's word is still reported). fileDescriptorStoreCount is how many descriptors the manager holds in the service's file descriptor store right now (NFileDescriptorStore): 0 for a service without a store and for a missing or masked unit, and possibly above 0 for a stopped service whose store is preserved. Missing and masked units remain distinct from inactive or disabled units. Aliases resolving to a different canonical unit are rejected.

systemdUnitQuiescence(state) reads from a snapshot, without I/O, whether the unit is at rest. It is the package's one notion of a stopped unit; decide on it instead of comparing subState strings. On systemd 254 and later a cleanly stopped service whose preserved file descriptor store (FileDescriptorStorePreserve=yes) still holds descriptors reads inactive/dead-resources-pinned, not inactive/dead, and is just as stopped:

| Field | Meaning | | --- | --- | | rest | 'stopped' for inactive with SubState dead or dead-resources-pinned, 'failed' for failed/failed, each only without a main or control process and without a control group; null for every other state (starting, running, stopping, about to restart, an exited oneshot) | | jobPending | a systemd job is queued for the unit; it does not change rest | | quiet | rest is not null and no job is queued: nothing of the unit runs or is about to | | resourcesPinned | fileDescriptorStoreCount is above 0, whatever the state; a failed unit with FileDescriptorStorePreserve=yes keeps its store too |

import { SystemdUnit, systemdUnitQuiescence } from '@push.rocks/smartdaemon';

const state = await unit.stop();
const { quiet, rest, resourcesPinned } = systemdUnitQuiescence(state);
if (quiet && rest === 'stopped') {
  // Stopped. With resourcesPinned the manager still holds the store for the next start;
  // cleanFileDescriptorStore() releases it.
}

start() and stop() await the systemctl command and return a fresh snapshot. They do not establish application readiness or prove database shutdown; callers must apply the owning application's lifecycle contract. A command failure or timeout rejects with SystemdUnitError and a local code, without returning raw command diagnostics. The systemd job can continue after a client timeout, so a failed command must not be treated as cancelled or successfully completed.

A Type=notify start returns only at READY=1, which can outlast commandTimeoutMs. start({ wait: false }) and stop({ wait: false }) pass --no-block: systemd verifies and enqueues the job, and the call returns the snapshot read right after, typically activating or deactivating with the job pending. A refused job (a missing or masked unit, a conflicting transaction) still rejects with command_failed. The caller then polls inspect(): job empties when the job finishes, statusText carries the service's progress, and result tells a start timeout (timeout) from a crash (exit-code, signal, core-dump). Options other than a boolean wait reject with invalid_options before any command runs.

const pending = await unit.start({ wait: false });
// Later, on the caller's own schedule:
const state = await unit.inspect();
if (state.job === '' && state.activeState === 'failed') {
  console.log(state.result === 'timeout' ? 'start stalled' : `crashed: ${state.result}`);
} else {
  console.log(state.activeState, state.statusText);
}

Calls use an absolute systemctlPath (default /usr/bin/systemctl), a clean environment, no shell or password prompt, and bounded output. Trusted owning code can choose another absolute executable path and commandTimeoutMs (default 120000, maximum 600000). This timeout bounds the client process only. The default scope: 'system' retains system-manager behavior. scope: 'user' uses systemctl --user as the calling user. Every SystemdUnit operation contacts the user manager, so each requires a real, owned, private XDG_RUNTIME_DIR (or an explicit runtimeDirectory) and passes that directory with the user's home and any set XDG_CONFIG_HOME and XDG_DATA_HOME to the command. Before each call, all of them must be absolute, normalized paths without a trailing slash, and the runtime directory is checked again on disk; a missing, malformed, replaced, linked or group-accessible runtime directory, a malformed XDG root and a missing or malformed home directory each fail with invalid_options before contacting systemd.

Construction validates only the options it is given: the unit name, systemctlPath, busctlPath, commandTimeoutMs, scope and a supplied runtimeDirectory, which must be a well-formed path (and is refused for system scope). It reads, but does not validate, the process environment above, so a unit can be constructed without a login session (cron, a plain ssh command) or with a malformed XDG variable; its calls then refuse. An empty XDG variable counts as unset. The environment is captured at construction; later changes to process.env do not affect an existing unit.

cleanFileDescriptorStore() releases the service's file descriptor store with systemctl clean --what=fdstore, closing every descriptor the manager holds for it, and returns a fresh snapshot; nothing else of the unit is cleaned. The manager refuses, and the call rejects with command_failed, unless the unit is loaded, inactive (a failed unit needs systemctl reset-failed first) and without a pending job, and its loaded FileDescriptorStoreMax is above zero or the store still holds a descriptor. A stopped unit whose store was pinned (dead-resources-pinned) reads dead after the release. Once the manager has accepted the clean, every outcome states that the store was released: the call resolves with the snapshot read afterwards, or, when that inspection fails, rejects with released_uninspected and the inspection's error as cause. The snapshot reports the unit as it is when read: its fileDescriptorStoreCount is 0 unless the unit was started right after the release and stored descriptors again, which a caller must not read as a failed clean. A refused clean, or one that could not be run, rejects with command_failed; so does a clean whose systemctl call exceeded commandTimeoutMs, whose outcome is unknown because the manager may have released the store before the client was killed. After command_failed a caller must inspect() the unit before treating the store as kept. Cleaning a store needs systemd 254 or later: the call reads the manager's version first and refuses an older manager with SystemdUnitManagerTooOldError (manager_too_old), an unreadable version with version_unknown and an unanswered read with command_failed, each without running the clean.

inspectConfiguration() reports the selected fragmentPath, opaque dropInPaths list and needsReload flag. reloadConfiguration() explicitly reloads the manager's unit definitions globally and returns a fresh configuration snapshot. Neither operation changes enablement or masks.

inspectServiceSettings() reports the loaded service settings a definition renders, drop-ins included: type, notifyAccess, restart, killMode (systemd's own words; type is empty for a unit systemd could not load), delegate, restartPreventExitStatus and successExitStatus (each { statuses, signals }: exit statuses ascending, and signals by systemd's name without SIG, such as TERM, which only a hand-written unit or drop-in sets), startLimitBurst, startLimitIntervalMicroseconds (0 when the start rate limit is disabled), restartMicroseconds, timeoutStopMicroseconds and timeoutStartMicroseconds (exact integers or 'infinity'; the start timeout is the loaded TimeoutStartSec=, without any EXTEND_TIMEOUT_USEC= extension a running start requested), limitNoFile and limitNoFileSoft (hard and soft), tasksMax (integers or 'infinity'), oomScoreAdjust, fileDescriptorStoreMax (0 without a store), fileDescriptorStorePreserve (no, yes or restart) and environment, the unit's own Environment= variables as a frozen name-to-value object (variables the manager gives every unit, EnvironmentFile= and PassEnvironment= are not included). A missing, repeated, foreign or malformed property rejects with invalid_state; this includes an Environment= value holding a control character, which systemctl show prints as a C escape. inspectServiceSettings() needs systemd 254 or later, the first to report FileDescriptorStorePreserve: it reads the manager's version first and refuses an older manager with SystemdUnitManagerTooOldError (manager_too_old) and an unreadable version with version_unknown, both before the unit is read (see The manager's version below). Every environment a SystemdServiceDefinition accepts reads back. Reload changed files first; the snapshot describes what the manager loaded.

execStart lists every ExecStart= command of the loaded unit, drop-ins included, in the order the manager runs them, each as a frozen ISystemdExecCommand: path (the executable), argv (the argument vector, argv[0] included, one string per argument exactly as loaded; % specifiers resolved as systemd loaded the unit, $VARIABLES and $$ as written) and ignoreFailure (a - prefix). It is empty for a unit that sets none or that the manager could not find. systemctl show joins argv with spaces, so an argument holding a space could not be told apart there; this field is read instead from the unit's D-Bus ExecStart property (a(sasbttttuii)) with busctl --json=short get-property, through an absolute busctlPath (default /usr/bin/busctl, systemd 240 or newer) with the same clean environment and timeout. The system scope reads the system bus; the user scope reads the user's bus at $XDG_RUNTIME_DIR/bus, so a user manager without a user D-Bus fails inspectServiceSettings() with command_failed. A failed bus read rejects with command_failed, and a reply that is not exactly that signature with an absolute path, a non-empty argv of strings and a boolean flag per command rejects with invalid_state.

const settings = await new SystemdUnit({ unitName: 'pallet-containerd.service' }).inspectServiceSettings();
const carriesContract = settings.type === 'notify' && settings.notifyAccess === 'all' &&
  settings.killMode === 'process' && settings.delegate && settings.limitNoFile === 'infinity' &&
  settings.limitNoFileSoft === 'infinity' && settings.tasksMax === 'infinity' &&
  settings.oomScoreAdjust === -999 && settings.restart === 'always' &&
  settings.restartMicroseconds === 2_000_000 && settings.timeoutStopMicroseconds === 60_000_000;
// The exact command line the manager runs, argument by argument.
const runsServe = settings.execStart.length === 1 &&
  settings.execStart[0].argv.join('\0') === ['/opt/pallet/pallet-control', 'containerd-serve'].join('\0');

enable() persistently enables the existing unit definition and returns a fresh state snapshot. It does not rewrite the definition, start the unit, unmask it or force conflicting links. Run it inside the owning installer's cross-process installation scope, after any required offline commissioning. A failed or timed out command can leave partial enablement; inspect and retry the exact operation without assuming cancellation or undoing the application's retained protection.

disable() is its counterpart: it removes the enablement links the unit's [Install] section names (systemctl disable) and returns a fresh snapshot. It does not stop a running unit, mask, unmask or remove the definition, so a disabled service keeps running until it is stopped. A failed or timed out command can leave partial enablement as well; inspect and retry. Disable a unit before SystemdUnitFile.uninstall() removes its definition.

inspectRelationships() returns immutable sorted arrays for before, after, requires, wants, conflicts, requiredBy and wantedBy from the loaded manager graph. Literal systemd hex escapes are preserved. The bounded snapshot rejects missing fields, foreign identities, duplicate relationships and malformed names. unitFileState === 'enabled' proves only that some enablement links exist. For a boot guard, verify that its requiredBy and before both include every required target and the actual network provider; inspect the provider's requires and after as well. Recheck the exact file digest, selected fragment, absence of drop-ins and needsReload === false before permitting dependent activation.

The manager's version

inspectManagerVersion() reads the running manager's version through the unit's scope — the system manager, or the calling user's manager for scope: 'user' — with systemctl show --property=Version, the same systemctlPath, environment and timeout as every other call. It resolves with a frozen ISystemdManagerVersion: major (255) and version, the manager's Version property verbatim (255.4-1ubuntu8.17). This is the running manager's version, which after a package upgrade differs from systemctl --version until the manager re-executes. A manager that does not answer rejects with command_failed; an answer that is not a single Version= line with a version starting with a major of one to four digits rejects with version_unknown.

requireManagerVersion(requiredMajor) resolves with that version when its major is at least requiredMajor (an integer from 1 to 9999; anything else rejects with invalid_options before any command) and otherwise rejects with a SystemdUnitManagerTooOldError: a SystemdUnitError with code manager_too_old that carries the managerVersion it read and the requiredMajor. Calls that need a newer manager use it and fail closed: inspectServiceSettings(), cleanFileDescriptorStore() and installing a definition with a requiredManagerMajor (see SystemdUnitFile.install()), so none fails late with a generic code. runInTransientScope() refuses an older user manager the same way with its own SystemdTransientScopeManagerTooOldError.

import { SystemdUnit, SystemdUnitManagerTooOldError } from '@push.rocks/smartdaemon';

const unit = new SystemdUnit({ unitName: 'pallet-node.service' });
console.log((await unit.inspectManagerVersion()).major); // 255
try {
  await unit.inspectServiceSettings();
} catch (error) {
  if (error instanceof SystemdUnitManagerTooOldError) {
    // e.g. 'systemd 253 (253.5-1) running, 254 required'
    console.error(`systemd ${error.managerVersion.major} (${error.managerVersion.version}) running, ` +
      `${error.requiredMajor} required`);
  }
  throw error;
}

Installing a direct systemd service definition

SystemdServiceDefinition encodes a literal executable and argument list, with explicit restart policy, kill mode and stop timeout. System-scope definitions require user and group; user-scope definitions reject both directives because the user manager already runs under the calling identity. It defaults to Type=exec, journal output (see standardOutput and standardError below) and null stdin. It introduces no shell; percent specifiers and argument environment expansion are escaped. Paths must be absolute and normalized. Descriptions, paths and arguments reject control characters, lone surrogates and Unicode noncharacters (U+FDD0–U+FDEF and every code point ending in FFFE or FFFF), which systemd does not accept as UTF-8 clean and would ignore, leaving the unit unloadable. Descriptions and working directories cannot have backslashes or surrounding whitespace; executable paths also reject quotes and backslashes. Arguments may contain spaces, quotes, backslashes and empty strings.

import { SystemdServiceDefinition, SystemdUnitFile } from '@push.rocks/smartdaemon';

const definition = new SystemdServiceDefinition({
  unitName: 'smartdaemon_my-api-server.service',
  description: 'My API server',
  executable: '/opt/my-api/bin/server',
  args: ['serve'],
  workingDirectory: '/opt/my-api',
  user: 'root',
  group: 'root',
  restart: 'no',
  killMode: 'mixed',
  timeoutStopSeconds: 360,
});
const file = new SystemdUnitFile({ unitName: definition.unitName });
// Inside the privileged installer's existing cross-process ownership scope:
const previous = await file.inspect();
const installed = await file.install(definition, previous?.sha256 ?? null);
console.log(installed.changed, installed.masked, installed.record.sha256);

For a user service, give the definition and installer the same scope:

const userDefinition = new SystemdServiceDefinition({
  scope: 'user',
  unitName: 'authswitch.service',
  description: 'Account authority',
  executable: '/opt/authswitch/bin/authswitch',
  args: ['daemon'],
  workingDirectory: '/opt/authswitch',
  restart: 'on-failure',
  killMode: 'mixed',
  timeoutStopSeconds: 60,
});
const userFile = new SystemdUnitFile({ scope: 'user', unitName: userDefinition.unitName });
const previousUserFile = await userFile.inspect();
await userFile.install(userDefinition, previousUserFile?.sha256 ?? null);

User definitions default to WantedBy=default.target and have no system-only After=local-fs.target edge. User unit files default to $XDG_DATA_HOME/systemd/user (or ~/.local/share/systemd/user), below the user's config directory in the unit load path. Installation and enablement use the current user manager and do not escalate privileges. The installer rejects unsafe ownership or group-writable path components; prepare those directories explicitly before installation. It never changes the permissions of existing directories.

A user-scope SystemdUnitFile validates each value where it is read:

  • Construction validates the unit's options as SystemdUnit does, a supplied unitDirectory and expectedUid (which must equal the caller's uid). Without unitDirectory, it also derives the public path from XDG_DATA_HOME, or from the home directory when that is unset, and rejects a missing or malformed source there with invalid_options; no other process value is validated at construction.
  • inspect() reads only the unit directory as that uid. It needs no user manager, no runtime directory and no XDG config root, so with an explicit unitDirectory it works whatever XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_RUNTIME_DIR and the home directory hold.
  • install() admits, reloads and verifies through the manager and recognizes the user's persistent and runtime mask paths. It therefore needs everything the manager calls need, plus a well-formed config root (XDG_CONFIG_HOME, or ~/.config), and rejects anything missing or malformed with invalid_options before touching any directory or running systemctl.
  • uninstall() needs the same and refuses the same way, before removing anything.

An owner can therefore ask whether its definition is installed from a context without a login session:

// Resolves to null when the file is absent, or { path, sha256 } for a protected definition.
const installed = await new SystemdUnitFile({ scope: 'user', unitName: 'authswitch.service' }).inspect();

inspect() and install() check every component of the unit directory from / down, then the unit file itself. A refusal is a SystemdUnitFileUnsafePathError, a SystemdUnitFileError with code unsafe_path that names the offending path and a reason of type TSystemdUnitFileUnsafePathReason, so the owner knows exactly what to fix:

| reason | path | Refused because | | --- | --- | --- | | unsupported_platform | unit directory | the process does not run on Linux | | foreign_process | unit directory | the process does not run as the expected uid | | symlink | directory component or unit file | it is a symbolic link | | not_directory | directory component | it is not a directory | | foreign_owner | directory component or unit file | a directory is owned by neither root nor the expected uid, or the file not by the expected uid | | group_or_world_writable | directory component | it is group- or world-writable (only a root-owned mode-1777 /tmp is admitted) | | unreadable | unit file | it cannot be opened; the operating-system error is the cause | | not_regular_file | unit file | it is not a regular file | | hard_linked | unit file | it has more than one hard link | | wrong_mode | unit file | its mode is not exactly 0644 | | too_large | unit file | it is larger than 16384 bytes | | replaced | unit directory or a file being written | it changed identity between two checks during install() |

import { SystemdUnitFileUnsafePathError } from '@push.rocks/smartdaemon';

try {
  await file.inspect();
} catch (error) {
  if (error instanceof SystemdUnitFileUnsafePathError) {
    // e.g. 'group_or_world_writable' '/home/user/.local' after `chmod 0775 ~/.local`
    console.error(error.reason, error.path);
  }
  throw error;
}

For a boot prerequisite that must finish before dependent services start, supply startup: { type: 'oneshot', remainAfterExit: true, timeoutStartSeconds: 120 }. Systemd waits for successful process exit; RemainAfterExit keeps the completed unit active. The start timeout is an integer from 1 to 600 seconds. Oneshot services reject restart: 'always'; the default exec definition remains unchanged.

A long-running service that reports its own readiness uses startup: { type: 'notify', notifyAccess: 'all', timeoutStartSeconds: 120 }, rendered as Type=notify, NotifyAccess= and TimeoutStartSec=. Systemd counts the start complete when a process admitted by notifyAccess sends READY=1: 'main' admits only the main process, 'exec' also the processes of the unit's Exec*= commands, 'all' every process in the unit's cgroup, as a ready notification from a child of the main process needs. NotifyAccess=none is not offered because it discards every notification. Notify services may use restart: 'always'.

These optional settings render after TimeoutStopSec= in this order, and nothing when omitted, so existing definitions keep their exact bytes:

| Option | Directive | Accepted values | | --- | --- | --- | | restartSeconds | RestartSec= (after Restart=) | integer 1–600; refused with restart: 'no' | | restartPreventExitStatus | RestartPreventExitStatus= (after RestartSec=) | distinct integers 0–255, at least one, rendered ascending; refused with restart: 'no' | | successExitStatus | SuccessExitStatus= (after RestartPreventExitStatus=) | distinct integers 0–255, at least one, rendered ascending | | startLimitIntervalSeconds | StartLimitIntervalSec= in [Unit], after the relations | integer 0–86400; 0 disables the start rate limit | | startLimitBurst | StartLimitBurst= in [Unit], after StartLimitIntervalSec= | integer 1–1000; refused with startLimitIntervalSeconds: 0 | | delegate | Delegate= | boolean | | limitNoFile | LimitNOFILE= (soft and hard) | positive integer or 'infinity' | | tasksMax | TasksMax= | positive integer or 'infinity' | | oomScoreAdjust | OOMScoreAdjust= | integer −1000–1000 | | environment | one Environment= line per variable, ordered by name | plain object of string values; see below |

const containerd = new SystemdServiceDefinition({
  unitName: 'pallet-containerd.service',
  description: 'Pallet containerd',
  executable: '/opt/pallet/current/pallet-control',
  args: ['containerd-serve'],
  workingDirectory: '/',
  user: 'root',
  group: 'root',
  restart: 'always',
  restartSeconds: 2,
  killMode: 'process',
  timeoutStopSeconds: 60,
  startup: { type: 'notify', notifyAccess: 'all', timeoutStartSeconds: 120 },
  dependencies: {
    defaultDependencies: true,
    after: ['pallet-guard.service'],
    before: ['pallet-node.service'],
    requires: ['pallet-guard.service'],
    wants: [],
    conflicts: [],
  },
  delegate: true,
  limitNoFile: 'infinity',
  tasksMax: 'infinity',
  oomScoreAdjust: -999,
});

A service that restarts on failure but must not loop on a permanent refusal names the exit statuses that end it for good, and disables the start rate limit so the manager's DefaultStartLimitIntervalSec=/DefaultStartLimitBurst= cannot make it give up during a long outage of something it depends on:

const node = new SystemdServiceDefinition({
  unitName: 'spark.service',
  description: 'serve.zone Spark node',
  executable: '/opt/spark/current/spark',
  args: ['runnode'],
  workingDirectory: '/opt/spark',
  user: 'root',
  group: 'root',
  restart: 'on-failure',
  restartSeconds: 5,
  killMode: 'mixed',
  timeoutStopSeconds: 360,
  restartPreventExitStatus: [78], // EX_CONFIG: a permanent refusal
  startLimitIntervalSeconds: 0,
});

renders StartLimitIntervalSec=0s as the last [Unit] line and Restart=on-failure, RestartSec=5s, RestartPreventExitStatus=78 in [Service]. Systemd 230 and later read the start rate limit from [Unit] only; under systemd 255 systemd-analyze verify loads such a unit without a warning.

Systemd applies these within what the manager itself may grant. Under systemd 255 in an unprivileged container, this unit started with the manager's own open-file hard limit for LimitNOFILE=infinity and without the lowered OOM score, and still reported active. inspectServiceSettings() reports the configured values, not the running process's limits.

environment gives the service's processes literal variables, for example the installer's PATH when the executable or the tools it starts live outside the manager's default search path (a user manager's default PATH omits ~/.local/bin and nvm's Node directories):

const authority = new SystemdServiceDefinition({
  scope: 'user',
  unitName: 'authority.service',
  description: 'Authority daemon',
  executable: '/home/owner/.nvm/versions/node/v24.9.0/bin/node',
  args: ['/home/owner/app/cli.js', 'serve'],
  workingDirectory: '/home/owner',
  restart: 'on-failure',
  killMode: 'mixed',
  timeoutStopSeconds: 30,
  environment: { PATH: process.env.PATH!, CODEX_HOME: '/home/owner/.codex' },
});
// Environment="CODEX_HOME=/home/owner/.codex"
// Environment="PATH=/home/owner/.nvm/versions/node/v24.9.0/bin:/home/owner/.local/bin:/usr/bin:/bin"

The value must be a plain object (not a proxy, class instance or null-prototype object) whose own properties are enumerable data properties. Names match [A-Za-z_][A-Za-z0-9_]* and have at most 128 characters; values are strings of at most 4096 characters without control characters, lone surrogates or Unicode noncharacters, and may be empty. At most 64 variables and 8192 UTF-8 bytes of NAME=value in total are accepted, and the whole unit stays within 16384 bytes. Anything else throws SystemdServiceDefinitionError. Each assignment is double-quoted with \ and " backslash-escaped and % doubled, so systemd applies no specifier; Environment= never expands $, so values are taken literally. An empty object renders nothing. Systemd merges these variables over its own (HOME, USER, INVOCATION_ID, …), so a name such as NOTIFY_SOCKET overrides the manager's value.

standardOutput and standardError say where the service's output streams go (StandardOutput=, StandardError=): 'journal', the default, 'null', which discards the stream, or { append: path }, which appends it to a file (append:, systemd 240 and later). Omitted, both render journal as before, so existing definitions keep their bytes.

const controller = new SystemdServiceDefinition({
  scope: 'user',
  unitName: 'app-controller.service',
  description: 'App controller',
  executable: '/home/owner/.local/share/pnpm/app',
  args: ['serve'],
  workingDirectory: '/home/owner',
  restart: 'on-failure',
  killMode: 'process',
  timeoutStopSeconds: 90,
  standardOutput: 'null',
  standardError: { append: '/home/owner/.config/app/logs/controller.log' },
});
// StandardOutput=null
// StandardError=append:/home/owner/.config/app/logs/controller.log

The path must be absolute and normalized, without a backslash, surrounding whitespace or a trailing slash; % is doubled, so systemd applies no specifier. The service manager opens the file before it invokes the executable and before it switches to the service's User=, so with its own privileges: as root under the system manager, as the user under a user manager. It creates a missing file owned by that user with mode 0666 masked by the unit's umask (0644 under the usual umask 0022). Its directory must already exist, or the service fails to start. A caller that needs another owner or a private file creates it with the owner and mode it wants first; an existing file keeps both. Systemd reads append: from version 240 and an older manager would ignore the line, so a definition with an append output has requiredManagerMajor 240 (254 with fileDescriptorStorePreserve as well) and SystemdUnitFile.install() refuses it on an older manager with manager_too_old. The manager reports only the kind of output (append) as a property, not the path, so inspectServiceSettings() does not report it: verify an installed definition by its digest.

Notify access and the file descriptor store

A service that keeps a descriptor across its own restarts, such as a network namespace a child process created, hands it to the manager with sd_notify FDSTORE=1 (optionally FDNAME=), and the manager passes it back to the next main process through LISTEN_FDS/LISTEN_FDNAMES:

| Option | Directive | Accepted values | | --- | --- | --- | | notifyAccess | NotifyAccess=, right after the startup lines | 'none', 'main', 'exec' or 'all'; refused beside a notify startup, which names its own | | fileDescriptorStoreMax | FileDescriptorStoreMax= (after OOMScoreAdjust=) | integer 1–65536 | | fileDescriptorStorePreserve | FileDescriptorStorePreserve= (after FileDescriptorStoreMax=) | 'no', 'yes' or 'restart'; requires fileDescriptorStoreMax |

'main' admits notifications from the main process only and 'exec' from the main and control processes the manager started; a descriptor a child of the main process stores needs 'all'. Without notifyAccess systemd 255 admits the main process for a service with a store, and it loads an explicit NotifyAccess=none beside a store as main, so 'none' is refused there. fileDescriptorStorePreserve: 'no' releases the store when the service stops, 'restart' (systemd's default) once it is neither active nor about to restart, and 'yes' only when the unit leaves the manager's memory or SystemdUnit.cleanFileDescriptorStore() releases it. A reboot empties the store; a systemctl soft-reboot keeps the running kernel, so a preserved store and the descriptors in it pass on to the next userspace boot. Systemd reads FileDescriptorStorePreserve= from version 254 and an older manager would ignore the line, so a definition with fileDescriptorStorePreserve has requiredManagerMajor 254 and SystemdUnitFile.install() refuses it on an older manager with SystemdUnitManagerTooOldError (manager_too_old) before touching anything. requiredManagerMajor is the highest major any rendered line needs (240 for an append output, see above); a definition with neither has it null. Sending FDSTORE=1 needs an AF_UNIX socket, so a definition with a store or a notify access other than 'none' that restricts its socket families must allow AF_UNIX. Omitted, each option renders nothing.

const node = new SystemdServiceDefinition({
  unitName: 'pallet-node.service',
  description: 'serve.zone Pallet node',
  executable: '/opt/pallet/current/pallet-control',
  args: ['runtime-unit'],
  workingDirectory: '/',
  user: 'root',
  group: 'root',
  restart: 'on-failure',
  restartSeconds: 5,
  killMode: 'mixed',
  timeoutStopSeconds: 360,
  notifyAccess: 'all',
  fileDescriptorStoreMax: 1,
  fileDescriptorStorePreserve: 'yes',
});
// Type=exec, NotifyAccess=all, …, FileDescriptorStoreMax=1, FileDescriptorStorePreserve=yes

Under systemd 255 systemd-analyze verify loads such a unit without a warning.

Credentials, a dynamic user and the sandbox

A system service can receive secrets as systemd credentials instead of variables, run as a transient account and be confined. Each option below renders nothing when omitted, so existing definitions keep their exact bytes; false renders the directive as no. A user service refuses all of them, since a user manager cannot grant them. DynamicUser= follows Group=; the others follow the environment lines in this order:

| Option | Directive | Accepted values | | --- | --- | --- | | dynamicUser | DynamicUser= | boolean; with true, user and group name the allocated account | | loadCredentials | one LoadCredential= line per credential, ordered by name | array of { name, source? } | | loadCredentialsEncrypted | one LoadCredentialEncrypted= line per credential, ordered by name | array of { name, source? } | | noNewPrivileges | NoNewPrivileges= | boolean | | protectSystem | ProtectSystem= | boolean, 'full' or 'strict' | | protectHome | ProtectHome= | boolean, 'read-only' or 'tmpfs' | | privateTmp, privateDevices, protectKernelTunables, protectKernelModules, protectKernelLogs, protectControlGroups, protectClock, protectHostname, restrictNamespaces, restrictRealtime, restrictSUIDSGID, lockPersonality | the directive of the same name, in this order | boolean | | capabilityBoundingSet | CapabilityBoundingSet= | distinct capabilities from systemdCapabilities, sorted; empty drops every capability | | ambientCapabilities | AmbientCapabilities= | distinct capabilities, sorted; within capabilityBoundingSet when both are given | | restrictAddressFamilies | RestrictAddressFamilies= | at least one distinct family of AF_INET, AF_INET6, AF_NETLINK, AF_PACKET, AF_UNIX, AF_VSOCK, sorted; includes AF_UNIX for a notify service |

const relay = new SystemdServiceDefinition({
  unitName: 'relay.service',
  description: 'Cluster relay',
  executable: '/opt/relay/current/relay',
  args: [],
  workingDirectory: '/opt/relay/current',
  user: 'relay',
  group: 'relay',
  dynamicUser: true,
  restart: 'always',
  restartSeconds: 2,
  killMode: 'mixed',
  timeoutStopSeconds: 60,
  dependencies: {
    defaultDependencies: true,
    after: ['network-online.target'],
    before: ['node.service'],
    requires: [],
    wants: ['network-online.target'],
    conflicts: [],
  },
  environment: { RELAY_BIND_PORT: '8443' },
  loadCredentials: [{ name: 'RELAY_AUTHORIZATION' }],
  noNewPrivileges: true,
  protectSystem: 'strict',
  protectHome: true,
  privateTmp: true,
  privateDevices: true,
  capabilityBoundingSet: [],
  restrictAddressFamilies: ['AF_INET', 'AF_INET6', 'AF_UNIX'],
});
// DynamicUser=yes
// LoadCredential=RELAY_AUTHORIZATION
// CapabilityBoundingSet=
// RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

A credential's value never appears in the unit, in the service's environment (so child processes do not inherit it) or in systemctl show: the service reads it from $CREDENTIALS_DIRECTORY/<name>, readable by the service's user only. name is 1–255 characters of [A-Za-z0-9_.@-], not starting with .. source is either an absolute normalized path of [A-Za-z0-9_.@/+-] characters without a trailing / (a file, a directory of files or an AF_UNIX socket), or a credential name that systemd looks up among its own credentials and then in /etc/credstore/, /run/credstore/ and /usr/lib/credstore/ (for encrypted credentials also the credstore.encrypted directories). Without source, systemd looks up name itself, so { name: 'RELAY_AUTHORIZATION' } reads /etc/credstore/RELAY_AUTHORIZATION; a source equal to name is refused, as it only spells the same lookup twice. These characters need no quoting and hold no % specifier, : separator or escape, so systemd reads each line exactly as written. Encrypted credentials are sealed with systemd-creds encrypt (host key or TPM2). Systemd keeps both kinds in one table by name, so a name appears in at most one list; at most 64 per list. The directory search needs systemd 250 or later; an absolute source works from systemd 247.

dynamicUser: true allocates the account when the service starts. Systemd would use a static account of the same name instead, so user and group must not be root, and they have at most 31 characters, the longest name systemd allocates. For such a service systemd enforces ProtectSystem=strict, PrivateTmp=yes, NoNewPrivileges=yes, RestrictSUIDSGID=yes and at least ProtectHome=read-only; a definition that states any of them weaker is refused, so the unit never reads as less confined than it is.

Each refusal throws SystemdServiceDefinitionError, whose option names the offending option (for every option of a definition, not only these) and whose message is Invalid direct systemd service definition: <option>.. option is 'options' for the definition object itself: not a plain object, an unknown or accessor key, or a unit over 16384 bytes. No refusal carries the offending value.

Optional dependencies describes the complete literal runtime relationships:

dependencies: {
  defaultDependencies: false,
  after: [],
  before: ['network-pre.target', 'shutdown.target', 'systemd-networkd.service'],
  requires: [],
  wants: ['network-pre.target'],
  conflicts: ['shutdown.target'],
},
install: { wantedBy: [], requiredBy: ['network-pre.target', 'systemd-networkd.service'] },

System definitions always retain After=local-fs.target. Disable default dependencies only for an intentionally early boot or late shutdown service. Every list is bounded to 32 distinct literal unit names, sorted deterministically; templates, specifiers, self references and contradictory direct relationships are rejected. These local checks do not prove the complete host dependency graph is free of cycles.

Consumers must declare both requires: ['the-bootstrap.service'] and after: ['the-bootstrap.service'] for a failed bootstrap to prevent their start. Ordering alone does not propagate failure. A prerequisite must actually fail its start on failure; a skipped condition is not equivalent. Reverse stop ordering keeps the prerequisite active until its ordered consumers stop. The example assumes the host uses systemd-networkd.service: requiring the guard only from network-pre.target does not block a network manager that merely orders itself after that target. Select and verify the actual host network owner's edges.

install supplies only WantedBy/RequiredBy metadata, with at least one target. The privileged installer must separately enable and verify the intended links; rendering or installing the file does not activate them. A successful oneshot proves only that its executable returned success, so that executable must finish and verify all required work before exiting. See the upstream service readiness and dependency semantics.

test/native/qualify-systemd.py exercises the generated units under Ubuntu systemd PID 1 across provisioning, successful cold boot and failed cold boot. It checks actual networkd failure coupling and reverse shutdown order in a disposable QEMU overlay without an external network backend. The fixture requires QEMU/KVM, OVMF, xorriso, Python PyYAML and global tsx. Supply the unmodified Ubuntu Noble 20260826 image with SHA-256 d0fe84bb5f80853425fa6be28e2c106f30104c3cfe8611933f2e65c9b63f0e30:

python3 test/native/qualify-systemd.py --image /path/to/noble-server-cloudimg-amd64.img \
  --work-root /tmp/smartdaemon-systemd-qualification-local

To exercise the public enablement and relationship APIs in the same guest, bundle test/native/activate-systemd.ts as a self-contained Node ESM file and also supply --node /path/to/node --activation-bundle /path/to/activate.mjs. Any library absent from the minimal image can be supplied explicitly with repeatable --node-library /path/to/libname.so.N. These hashed test inputs travel on a read-only ISO. The API fixture checks enablement without implicit start, oneshot readiness, unchanged definition/configuration, detection of a missing provider link despite enabled state, exact enable retry and preservation of a mask.

Installation requires Linux, the relevant manager's user identity and a quiescent exact unit with no process, cgroup or job. It rejects effective drop-ins and shadowing fragments. Definitions default to /usr/local/lib/systemd/system for system scope and the user's data unit directory for user scope, below administrator or user configuration in the load path, so existing persistent/runtime masks remain intact. Existing enablement is preserved; an absent unit becomes disabled. It never enables, unmasks, starts or stops a service, or invokes sudo.

A quiescent unit is one systemdUnitQuiescence() reads as quiet, so a stopped service whose preserved file descriptor store holds descriptors (dead-resources-pinned) is admitted, and installation keeps that store: the manager carries the descriptors across the daemon-reload and hands them back to the reloaded unit, up to the reloaded definition's FileDescriptorStoreMax, and closes any beyond it. Only SystemdUnit.cleanFileDescriptorStore() releases a store on purpose.

Masked system units may report /dev/null or their exact administrator mask path: /etc/systemd/system/<unit> for a persistent mask, /run/systemd/system/<unit> for a runtime mask. Installation preserves that exact manager-selected fragment through the write and reload, and never replaces the masking fragment itself. For user scope, the exact user config or runtime mask path and the global user-unit mask paths are also recognized. Pending manager reloads are rejected before writing; inspect and explicitly reload an existing changed configuration before retrying installation.

Each of these manager checks refuses with a SystemdUnitFileUnsafeUnitError, a SystemdUnitFileError with code unsafe_unit whose reason of type TSystemdUnitFileUnsafeUnitReason says what to resolve. It also carries the state snapshot (ISystemdUnitState) the check read and, for the configuration reasons, the configuration snapshot (ISystemdUnitConfigurationState), otherwise null:

| reason | configuration | Refused because | | --- | --- | --- | | active | null | the unit is not at rest (systemdUnitQuiescence()): it has a main or control process or a control group, or is neither inactive/dead, inactive/dead-resources-pinned nor failed/failed: stop it first | | job_pending | null | a systemd job is queued for the otherwise quiet unit | | unexpected_load_state | null | the load state is none of loaded, masked and not-found | | unexpected_file_state | null | the unit-file state does not fit the load state (loaded: enabled, enabled-runtime or disabled; masked: masked or masked-runtime; not-found: none) | | reload_pending | snapshot | the manager needs a daemon-reload for the unit | | drop_ins | snapshot | the unit has effective drop-in files (dropInPaths) | | unexpected_fragment | snapshot | the selected fragmentPath is not the unit path (none for an absent unit), or a masked unit's is neither /dev/null nor its matching mask path, or is the unit path itself |

import { SystemdUnitFileUnsafeUnitError } from '@push.rocks/smartdaemon';

try {
  await file.install(definition, installed?.sha256 ?? null);
} catch (error) {
  if (error instanceof SystemdUnitFileUnsafeUnitError && error.reason === 'active') {
    // e.g. 'active' 'running' 4711: the service must be stopped before its definition is replaced
    console.error(error.state.activeState, error.state.subState, error.state.mainPid);
  }
  throw error;
}

The same checks rerun after the manager reload; a refusal there is reported as reload_failed, because the new definition is already in place, and the SystemdUnitFileUnsafeUnitError is its cause. Every other reload_failed also keeps the underlying error as cause, such as the SystemdUnitError of a failed systemctl daemon-reload.

The caller must already serialize privileged installation across processes; SystemdUnitFile only excludes simultaneous operations on the same instance. The expected previous SHA-256 is mandatory (null requires an absent file), while already matching bytes are idempotent. Installation checks protected directories and regular single-link mode-0644 files, retains changed definitions as <unit>.previous-<sha256>, flushes them before atomic replacement, reloads the manager and checks its selected fragment and final bytes. A reload failure leaves the new file and retained previous definition in place; recovery requires inspection, never an automatic rollback. A definition whose requiredManagerMajor is not null is admitted only on a manager at least that new, read with SystemdUnit.requireManagerVersion() before any directory, file or other systemctl effect; that read's refusal propagates unchanged as the unit's SystemdUnitError: manager_too_old (a SystemdUnitManagerTooOldError), version_unknown, or command_failed when the manager does not answer. Filesystem and definition errors use SystemdUnitFileError with a local code; every unsafe_path refusal is a SystemdUnitFileUnsafePathError naming the offending path and its reason, and every unsafe_unit refusal is a SystemdUnitFileUnsafeUnitError naming its reason with the manager snapshots it read. Trusted qualification code can set unitDirectory, expectedUid and the inherited SystemdUnit command options; user scope requires expectedUid to match the caller's actual uid. The manager's selected fragment must match the installed file after reload, so an unrecognized custom user unit path fails closed.

Removing an installed definition

uninstall(expectedSha256) removes what install() wrote: the unit file, when it holds exactly the bytes expectedSha256 digests, and every previous definition retained beside it (<unit>.previous-<sha256>), then reloads the manager and verifies its view.

const unit = new SystemdUnit({ scope: 'user', unitName: 'authswitch.service' });
const userFile = new SystemdUnitFile({ scope: 'user', unitName: 'authswitch.service' });
await unit.stop();
await unit.disable();
const installed = await userFile.inspect();
if (installed) {
  const result = await userFile.uninstall(installed.sha256);
  console.log(result.removed, result.masked, result.removedPaths);
}

It admits the unit only at rest (systemdUnitQuiescence()) with no job queued, no pending reload and no drop-ins, while the manager still selects this file, and refuses with the same SystemdUnitFileUnsafeUnitError reasons as installation. Removal admits fewer unit-file states: a loaded unit must be disabled, so no enablement link outlives its definition (an enabled one is refused with unexpected_file_state; disable it first), a masked unit keeps its mask, and a unit the manager does not find is admitted. A stopped service whose killMode: 'process' left processes running still has a control group and is refused with active until they end.

Every file is checked as installation checks the unit file (regular, single link, mode 0644, owned by the expected uid) and is removed only while its name still holds the file that was checked; a retained definition whose bytes do not match the digest in its name, or a unit file with other bytes than expected, refuses the removal with changed before anything is removed. After removal the manager must report the unit not found (still masked, for a masked unit) and at rest, or the call rejects with reload_failed, the files already removed and the cause attached. With nothing to remove the call resolves { removed: false, … } without reloading the manager. removed reports whether the unit file itself was removed and removedPaths every file that was. Removal never stops, disables or unmasks the unit.

With killMode: 'mixed', the main process owns graceful child shutdown and must remain alive until its children have joined. Systemd sends SIGTERM to the main process first and can send SIGKILL to the whole cgroup when the main process exits or the stop deadline expires. control-group sends the initial signal to the whole group. process signals only the main process, on stop and on a stop timeout; every other process in the unit survives, so the main process must own their shutdown or their deliberate retention (as with Delegate=yes workloads). A successful systemd start still requires application readiness verification, and a completed stop alone does not prove database drain.

The service side: notifications and passed descriptors (/systemd-runtime, Node.js 26)

The definitions above tell the manager what to expect; a Node.js main process answers it through the @push.rocks/smartdaemon/systemd-runtime subpath: sd_notify(3) messages (with descriptors for the store) and the descriptors the manager passes (LISTEN_FDS). Node.js has no AF_UNIX datagram socket and no fcntl(2), so the subpath calls glibc through node:ffi:

  • It requires Node.js 26 or later. Importing it on an older Node.js throws SystemdRuntimeError with code runtime_unsupported; the main entry is unaffected and keeps its Node.js range. The package is ES modules: CommonJS can require() either entry only on a Node.js that loads ES modules through require() by default (20.19+, 22.12+, 23+), where the subpath throws the same runtime_unsupported below 26; an older Node.js throws ERR_REQUIRE_ESM.
  • It runs on Linux x64 and arm64 with glibc (libc.so.6). Elsewhere, and with node:ffi unavailable (--no-experimental-ffi) or denied by the permission model, the first call that needs libc throws runtime_unsupported. Under the permission model pass --allow-ffi.
  • node:ffi is experimental: loading it prints one ExperimentalWarning. A single executable application suppresses it through its execArgv (--disable-warning=ExperimentalWarning).
  • libc is opened on first use. Outside systemd nothing is loaded: without NOTIFY_SOCKET SystemdNotifier.fromEnvironment() returns null, and without LISTEN_* variables takeSystemdListenDescriptors() returns an empty list.
import {
  SystemdNotifier,
  takeSystemdListenDescriptors,
  watchdogIntervalUsec,
} from '@push.rocks/smartdaemon/systemd-runtime';

// Descriptors passed for this process, numbered from 3, already close-on-exec.
const passed = takeSystemdListenDescriptors(); // [{ descriptor: 3, name: 'pallet-router' }]
const namespaceDescriptor = passed.find(({ name }) => name === 'pallet-router')?.descriptor ?? createNamespace();

// null when no service manager listens; the caller decides what that means.
const notifier = SystemdNotifier.fromEnvironment({ take: true });
if (notifier) {
  notifier.extendTimeout(60_000_000, 'replaying journal');
  notifier.replaceDescriptors('pallet-router', [namespaceDescriptor], { poll: false });
  notifier.ready('serving');
  const interval = watchdogIntervalUsec();
  if (interval !== null) setInterval(() => notifier.watchdog(), interval / 2000);
}

| Call | Sends or does | | --- | --- | | ready(status?) | READY=1 (and STATUS=) | | status(text) | STATUS= | | stopping(status?) | STOPPING=1 | | reloading(status?) | RELOADING=1 with MONOTONIC_USEC= (CLOCK_MONOTONIC); send ready() when done | | watchdog() | WATCHDOG=1 | | extendTimeout(usec, status?) | EXTEND_TIMEOUT_USEC= | | storeDescriptors(name, fds, { poll? }) | FDSTORE=1, FDNAME=, FDPOLL=0 for poll: false; descriptors as SCM_RIGHTS | | removeDescriptors(name) | FDSTOREREMOVE=1, FDNAME= | | replaceDescriptors(name, fds, { poll? }) | remove, then store: two messages, handled in order | | send(fields, fds?) | any NAME=value fields in insertion order | | takeSystemdNotifySocket() | removes NOTIFY_SOCKET from the environment and returns it validated, or null | | takeSystemdListenDescriptors() | removes LISTEN_PID/LISTEN_FDS/LISTEN_FDNAMES; for this process marks every passed descriptor close-on-exec and returns it with its name (unknown without names) | | setCloseOnExec(fd) | fcntl(F_SETFD, FD_CLOEXEC), verified | | watchdogIntervalUsec() | WATCHDOG_USEC for this process (WATCHDOG_PID), or null |

Every call is synchronous and either completes or throws SystemdRuntimeError (code, and errno when a libc call failed); nothing is dropped silently. systemd ignores a message it cannot parse without telling the sender, so the notifier refuses it first: field names are upper-case, values carry no newline or NUL, a message is at most 4096 bytes (notify_field_invalid); a descriptor name is 1–255 printable ASCII characters without : (descriptor_name_invalid); one message carries 1–253 descriptors (descriptor_invalid). NOTIFY_SOCKET must be an absolute path or an abstract name (@name) of at most 107 bytes; anything else, vsock: addresses included, is notify_socket_invalid. A send the kernel refuses is notify_failed with its errno (ENOENT for a missing path, ECONNREFUSED for an abstract name nobody holds). LISTEN_* variables naming another process are removed and nothing else is touched; a partial or malformed set (LISTEN_FDS not a positive integer, a names list of another length) is listen_environment_invalid before any descriptor is touched, and a passed descriptor that is not open is close_on_exec_failed.

LISTEN_FDS is also bounded by the process's hard RLIMIT_NOFILE before any descriptor is touched: a count whose last descriptor (2 + count) is at or above that limit is listen_environment_invalid. No descriptor systemd passed can lie at or above the hard limit, because it moves them into place with fcntl(F_DUPFD) (which refuses a number at or above the soft limit) after applying the unit's limits; a wrapper that lowers its hard limit below a passed descriptor before starting Node.js is refused. The hard limit can be as large as fs.nr_open (LimitNOFILE=infinity), so nothing is allocated per counted descriptor up front: like sd_listen_fds(3), the descriptors are marked one by one from 3, the first that is not open throws close_on_exec_failed (the ones before it keep the flag), and the list grows only with open descriptors.

Sends are blocking sendmsg(2) calls on a fresh AF_UNIX datagram socket, the same as sd_notify(3): when the manager's receive queue is full, the call waits for room and the event loop stalls for that time.

node:ffi has no errno API: the subpath reads glibc's thread-local errno through __errno_location() immediately after the failed call. Node.js itself calls libuv's uv_disable_stdio_inheritance() at startup (InitializeNodeWithArgsInternal() in src/node.cc; deps/uv/src/unix/core.c, Node.js 25.9.0 and 26.11.1), which marks descriptors 0–15 close-on-exec and continues past 15 until the first descriptor that is not open. The subpath still sets and verifies the flag on every passed descriptor, as sd_listen_fds(3) does.

test/test.systemdruntimeunit.node.ts proves the subpath under a real Type=notify unit of the caller's user manager (systemd-run --user), and skips with the reason without Node.js 26 or a user manager.

Starting a program in its own transient scope

A service that launches a long-lived program keeps that program in its own control group, so stopping the service ends the program too, even when the program detached itself with setsid. runInTransientScope() starts a program in a new transient scope unit of the calling user's systemd manager instead (systemd-run --user --scope): the program and every process it starts run in that scope and outlive the caller's unit. The call waits for the program's direct process and returns its result; a program that daemonizes and exits returns while its daemon keeps the scope active, and the scope ends when its last process exits.

import { runInTransientScope, SystemdTransientScopeError } from '@push.rocks/smartdaemon';

try {
  const result = await runInTransientScope({
    scope: 'user',
    description: 'Codex app-server daemon',
    executable: '/home/owner/.local/bin/codex',
    args: ['app-server', 'daemon', 'start'],
    workingDirectory: '/home/owner',
    environment: { HOME: '/home/owner', CODEX_HOME: '/home/owner/.codex', PATH: '/usr/bin:/bin' },
  });
  console.log(result.unitName, result.exitCode, result.stdout);
} catch (error) {
  if (error instanceof SystemdTransientScopeError && error.code === 'user_manager_unavailable') {
    // No user manager: the caller decides how to run the program without one.
  }
  throw error;
}

The result holds the scope's unitName, the direct process's exitCode (or the signal that ended it), and its stdout and stderr, UTF-8 decoded. A failure status is a result, not an error