No description
  • Swift 96.6%
  • Makefile 2.7%
  • Shell 0.7%
Find a file
2026-06-15 09:15:21 -04:00
launchd feat: rename app to Servant 2026-05-23 19:31:15 -04:00
Packaging fix: stop services during package upgrades 2026-05-23 21:28:11 -04:00
Resources fix: reconcile changed service configs 2026-05-24 02:28:52 -04:00
Sources/Servant feat: add Apple container supervision shown only when runtime installed 2026-06-15 08:19:53 -04:00
Tests/ServantTests feat: add Apple container supervision shown only when runtime installed 2026-06-15 08:19:53 -04:00
Tools fix: make icon generation deterministic 2026-05-23 21:19:24 -04:00
.gitignore fix: write artifacts to dst and disable pkg relocation 2026-05-23 18:58:48 -04:00
config.example.json feat: add Apple container supervision shown only when runtime installed 2026-06-15 08:19:53 -04:00
Makefile chore: sign pkg with Developer ID Installer, build dist by default 2026-06-15 09:15:21 -04:00
README.md feat: add Apple container supervision shown only when runtime installed 2026-06-15 08:19:53 -04:00

Servant

Native macOS menu-bar app that starts, supervises, and schedules your own command-line apps.

The supervisor is written in Swift/AppKit. The child apps can be Go binaries, shell scripts, or any executable.

The menu-bar item has a dashboard window with separate tabs for supervised services, periodic cron tasks, and Apple containers.

The app icon is generated locally during the build from Tools/make_icon.swift.

Build

Requirements:

  • macOS with Xcode Command Line Tools installed
  • make
  • xcrun, swiftc, codesign, pkgbuild, and plutil from Apple developer tools

Generated files are split by purpose:

  • build/ contains intermediate build products and the local .app bundle.
  • dst/ contains release artifacts to upload or deploy.

Build the app bundle:

make app

The app bundle is written to:

build/Servant.app

Run it manually:

open build/Servant.app

Install it for the current user:

make install

Default install path:

~/Applications/Servant.app

Override the install path:

make install APP_INSTALL_DIR="/Applications"

Build a specific version:

make app APP_VERSION=0.2.0

Remove generated build products and release artifacts:

make clean

Clean only one generated area:

make clean-build
make clean-artifacts

If a manually expanded installer payload leaves root-owned files under build/, remove that stale build/ directory from Terminal with sudo rm -rf build.

Tests

Run the Swift unit tests:

make test

The test runner is built with xcrun swiftc and writes to build/servant-tests.

Release Artifacts

Create a release zip:

make dist APP_VERSION=0.2.0

Outputs:

dst/Servant-0.2.0.zip

Override the artifact output directory:

make dist DST_DIR=/tmp/servant-release APP_VERSION=0.2.0

The release zip is a plain archived .app bundle for manual distribution.

Build a macOS installer package for MDM deployment:

make pkg APP_VERSION=0.2.0

Output:

dst/Servant-0.2.0.pkg

The package is for MDM deployment and direct installation with Apple Installer.

The package installs:

  • /Applications/Servant.app
  • /Library/LaunchAgents/com.vitalvas.servant.plist

The app bundle is marked non-relocatable in the package metadata, so Installer updates /Applications/Servant.app instead of searching for and updating another copy elsewhere on disk.

The package preinstall script unloads the running LaunchAgent for the current console user and stops the running app. The postinstall script fixes ownership and permissions, then loads and starts the LaunchAgent for the current console user.

Package defaults:

PKG_IDENTIFIER=com.vitalvas.servant.pkg
PKG_VERSION=<APP_VERSION>
LAUNCH_AGENT_LABEL=com.vitalvas.servant

Override package metadata:

make pkg \
  APP_VERSION=0.2.0 \
  PKG_IDENTIFIER="com.example.servant.pkg" \
  LAUNCH_AGENT_LABEL="com.example.servant"

Install locally for testing:

sudo installer -pkg dst/Servant-0.2.0.pkg -target /

Sign the installer package when needed:

make pkg APP_VERSION=0.2.0 PKG_SIGN_IDENTITY="Developer ID Installer: Your Name (TEAMID)"

Unsigned packages are usually acceptable for internal MDM testing. For production MDM deployment, sign the pkg with a Developer ID Installer certificate if your MDM or Gatekeeper policy requires it.

Remove a package-installed app manually:

sudo launchctl bootout gui/$(id -u) /Library/LaunchAgents/com.vitalvas.servant.plist
sudo rm -f /Library/LaunchAgents/com.vitalvas.servant.plist
sudo rm -rf /Applications/Servant.app

Forget the local installer receipt:

sudo pkgutil --forget com.vitalvas.servant.pkg

For MDM deployment, upload the generated .pkg to the MDM service and assign it to the target Macs.

Config

Default config path:

~/Library/Application Support/Servant/config.json

The app creates a disabled example config there on first launch. You can also start it with a custom config:

Servant.app/Contents/MacOS/Servant --config /path/to/config.json

Or set SERVANT_CONFIG:

SERVANT_CONFIG=/path/to/config.json open build/Servant.app

Each service and cron task must use exactly one of:

  • program plus optional arguments
  • shell, optionally with shellExecutable

Health checks are optional. If configured, HTTP 2xx means healthy unless expectedStatus is set.

Service controls update desired state. Start and enabled app launch set desired state to running; Stop sets desired state to stopped. Servant reconciles desired state against the current process state every 5 seconds, so a manually stopped service stays stopped until you start it again.

Reloading config preserves unchanged services. New or changed enabled services start with the new config, and removed or disabled services stop.

Cron tasks are configured separately under cronTasks. They appear in the dashboard's Cron Tasks tab and run one command per scheduled fire.

{
  "cronTasks": [
    {
      "name": "cleanup",
      "enabled": true,
      "schedule": "*/15 * * * *",
      "program": "~/bin/cleanup",
      "arguments": ["--older-than", "24h"],
      "timeoutSeconds": 300
    }
  ]
}

Cron task fields:

  • name: task name shown in the dashboard and menu.
  • enabled: optional, defaults to true.
  • schedule: five-field cron expression.
  • program plus optional arguments, or shell plus optional shellExecutable.
  • workingDirectory: optional working directory.
  • environment: optional environment overrides.
  • timeoutSeconds: optional task timeout. If exceeded, Servant sends terminate, then force-kills after 8 seconds if needed.
  • logFile: optional log path override.

Cron schedules use numeric fields:

minute hour day-of-month month day-of-week

Supported syntax:

  • *
  • */15
  • 1,2,3
  • 1-5
  • combinations such as 1-5,7

Day of week uses 0 or 7 for Sunday. When both day-of-month and day-of-week are restricted, Servant follows normal crontab behavior and runs when either field matches.

Cron task lifecycle:

  • Unchanged tasks are preserved on config reload.
  • Changed, removed, or disabled tasks are stopped and replaced or removed.
  • Scheduled runs execute once and exit.
  • If a scheduled run arrives while the previous run is still running, Servant skips the overlapping run.
  • Manual Run Task and Stop Task are available from the Cron Tasks tab.
  • Running cron tasks are stopped when Servant quits or during package upgrade shutdown.
  • The dashboard shows enabled state, schedule, state, last run, next run, exit code, and command.

Current crontab limitations:

  • No @reboot, @hourly, @daily, or similar nicknames.
  • No month or weekday names such as JAN or MON.
  • No crontab environment lines such as MAILTO=.
  • No % stdin splitting.
  • No system crontab user column.
  • No catch-up for missed runs while Servant was not running.

Containers are configured separately under containers. They appear in the dashboard's Containers tab and are supervised with the Apple container CLI (the containerization framework on macOS 26+). Each entry maps to one detached, named container that Servant keeps running.

{
  "containers": [
    {
      "name": "web",
      "enabled": true,
      "image": "docker.io/library/nginx:latest",
      "publish": ["127.0.0.1:8081:80"],
      "environment": { "ENV": "production" },
      "restart": true,
      "restartDelaySeconds": 3,
      "pullPolicy": "missing",
      "healthCheck": {
        "url": "http://127.0.0.1:8081/",
        "restartOnUnhealthy": true
      }
    }
  ]
}

Container fields:

  • name: container name shown in the dashboard and menu. Also used as the --name for the running container.
  • image: required image reference to run.
  • enabled: optional, defaults to true.
  • arguments: optional arguments passed to the container entrypoint (after the image).
  • containerArguments: optional extra flags passed to container run (before the image), such as ["--cpus", "2"].
  • publish: optional list of port publish mappings, each passed as --publish.
  • volumes: optional list of volume mounts, each passed as --volume.
  • environment: optional environment overrides, each passed as --env KEY=VALUE.
  • restart: optional, defaults to true. Auto-restarts the container after an unexpected stop.
  • restartDelaySeconds: optional restart delay, defaults to 3.
  • pullPolicy: optional, one of always, missing (default), or never. missing pulls only when the image is not present locally.
  • logFile: optional log path override. Output of the container CLI invocations is captured here.
  • healthCheck: optional HTTP health check, same fields as services.

Container lifecycle:

  • Containers are shown and served only when the Apple container runtime is installed. On launch Servant probes for the CLI and runs container --version to confirm it is runnable. If the runtime is not installed, the Containers tab and menu section are hidden and any configured containers are ignored without error.
  • The Apple container CLI is located at /usr/local/bin/container, /opt/homebrew/bin/container, or /usr/bin/container. Override with SERVANT_CONTAINER_BIN. The override is honored only when it points at an executable file.
  • The Apple container API server is a shared per-user daemon that must be running before any container can start. When a config has containers, Servant checks the daemon with container system status and starts it with container system start if it is down, before launching containers. If the daemon cannot be started, Servant shows a container system unavailable error in the menu and dashboard instead of attempting to launch.
  • Start and enabled-on-launch set desired state to running; Stop sets it to stopped. Servant reconciles every 5 seconds, the same as services.
  • A start pulls per the pullPolicy, removes any stale container with the same name, then runs container run --detach --name <name>.
  • Stop runs container stop <name>, falling back to container delete --force <name> if the graceful stop fails.
  • Unhealthy containers restart when healthCheck.restartOnUnhealthy is enabled.
  • Unchanged containers are preserved on config reload. Changed, removed, or disabled containers are stopped and replaced or removed.
  • Running containers are stopped when Servant quits or during package upgrade shutdown.
  • The dashboard shows enabled state, desired state, current runtime state, health, and image. Start, Stop, and Restart are available from the Containers tab.

See config.example.json.

Login Startup

Install the app, generate a LaunchAgent with the current install path, then load it:

make install-launchagent
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.vitalvas.servant.plist
launchctl enable gui/$(id -u)/com.vitalvas.servant
launchctl kickstart -k gui/$(id -u)/com.vitalvas.servant

Override install paths without editing repo files:

make install-launchagent APP_INSTALL_DIR="$HOME/Applications" APP_IDENTIFIER="com.example.servant"

For a custom label, use the matching generated plist name:

mkdir -p ~/Library/LaunchAgents
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.servant.plist
launchctl enable gui/$(id -u)/com.example.servant
launchctl kickstart -k gui/$(id -u)/com.example.servant

Unload the LaunchAgent:

launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.vitalvas.servant.plist

Disable it without removing the plist:

launchctl disable gui/$(id -u)/com.vitalvas.servant

Logs

Default child process logs:

~/Library/Logs/Servant/<service-name>.log

Set logFile per service to override it.

Default cron task logs:

~/Library/Logs/Servant/cron-<task-name>.log

Set logFile per cron task to override it.

Logs rotate at 50 MB. Servant keeps the current log plus four rotated files:

<service-name>.log
<service-name>.log.1
<service-name>.log.2
<service-name>.log.3
<service-name>.log.4