- Swift 96.6%
- Makefile 2.7%
- Shell 0.7%
| launchd | ||
| Packaging | ||
| Resources | ||
| Sources/Servant | ||
| Tests/ServantTests | ||
| Tools | ||
| .gitignore | ||
| config.example.json | ||
| Makefile | ||
| README.md | ||
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
makexcrun,swiftc,codesign,pkgbuild, andplutilfrom Apple developer tools
Generated files are split by purpose:
build/contains intermediate build products and the local.appbundle.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:
programplus optionalargumentsshell, optionally withshellExecutable
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 totrue.schedule: five-field cron expression.programplus optionalarguments, orshellplus optionalshellExecutable.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:
**/151,2,31-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 TaskandStop Taskare available from theCron Taskstab. - 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
JANorMON. - 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--namefor the running container.image: required image reference to run.enabled: optional, defaults totrue.arguments: optional arguments passed to the container entrypoint (after the image).containerArguments: optional extra flags passed tocontainer 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 totrue. Auto-restarts the container after an unexpected stop.restartDelaySeconds: optional restart delay, defaults to3.pullPolicy: optional, one ofalways,missing(default), ornever.missingpulls only when the image is not present locally.logFile: optional log path override. Output of thecontainerCLI invocations is captured here.healthCheck: optional HTTP health check, same fields as services.
Container lifecycle:
- Containers are shown and served only when the Apple
containerruntime is installed. On launch Servant probes for the CLI and runscontainer --versionto confirm it is runnable. If the runtime is not installed, theContainerstab and menu section are hidden and any configured containers are ignored without error. - The Apple
containerCLI is located at/usr/local/bin/container,/opt/homebrew/bin/container, or/usr/bin/container. Override withSERVANT_CONTAINER_BIN. The override is honored only when it points at an executable file. - The Apple
containerAPI 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 withcontainer system statusand starts it withcontainer system startif it is down, before launching containers. If the daemon cannot be started, Servant shows acontainer system unavailableerror in the menu and dashboard instead of attempting to launch. Startand enabled-on-launch set desired state to running;Stopsets 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 runscontainer run --detach --name <name>. Stoprunscontainer stop <name>, falling back tocontainer delete --force <name>if the graceful stop fails.- Unhealthy containers restart when
healthCheck.restartOnUnhealthyis 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, andRestartare available from theContainerstab.
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