Skip to content

feat: local macOS launchd installer with scheduled pipelines#405

Merged
norrietaylor merged 3 commits intomainfrom
feat/local-macos-launchd
Apr 24, 2026
Merged

feat: local macOS launchd installer with scheduled pipelines#405
norrietaylor merged 3 commits intomainfrom
feat/local-macos-launchd

Conversation

@norrietaylor
Copy link
Copy Markdown
Owner

@norrietaylor norrietaylor commented Apr 23, 2026

Summary

  • One-shot installer (scripts/local-macos/install.sh) that sets up a self-hosted Distillery HTTP server as a macOS LaunchAgent, plus four scheduled webhook agents (poll / classify / rescore / maintenance) and a weekly image-update agent.
  • All ingestion goes through the running server's /api/* surface — DuckDB is single-writer and the server holds the lock, so sibling CLI processes (docker exec ... distillery poll) can't write. The four worker scripts curl the webhooks with a bearer token stored in the login Keychain.
  • New docs/getting-started/local-macos.md page walks through prerequisites, install, verification, customization, and troubleshooting. Added to the MkDocs nav under Getting Started.

What gets installed

LaunchAgent Cadence Endpoint
local.distillery supervised GHCR container (ghcr.io/norrietaylor/distillery:latest)
local.distillery-update Mon 09:00 docker pull; kickstart if digest changed
local.distillery-poll every 30 min POST /api/poll
local.distillery-classify every 2 h POST /api/hooks/classify-batch
local.distillery-rescore daily 04:15 POST /api/rescore
local.distillery-maintenance Mon 05:00 POST /api/maintenance

Secrets live in the macOS login Keychain (JINA_API_KEY, DISTILLERY_WEBHOOK_SECRET). The webhook secret is generated on first install and reused on re-runs. Installer is idempotent; uninstaller preserves DB and Keychain entries by default (--purge-data, --purge-secrets flags opt-in to destruction).

Design notes

  • Docker path auto-detection: checks command -v docker, then ~/.orbstack/bin/docker, /usr/local/bin/docker, /opt/homebrew/bin/docker. Works with both OrbStack and Docker Desktop.
  • Platform: linux/amd64. Runs under Rosetta on Apple Silicon; no separate arm64 manifest is published on GHCR.
  • Worker scripts source a shared _webhook_common.sh so auth / reachability / error-handling is fixed in one place. Treats 202/409/429 as success (queued / in-flight / cooldown) — those are benign control-flow signals from the server, not failures.
  • Plist templates use __HOME__ and __DOCKER_DIR__ placeholders, substituted at install time with sed.
  • Catch-up on wake: calendar-based agents (update, rescore, maintenance) set StartCalendarIntervalRunOnMissed=true so they fire on next wake if the Mac was asleep at the scheduled minute.

Test plan

  • bash -n install.sh && bash -n uninstall.sh clean
  • zsh -n on all template shell scripts clean
  • plutil -lint on all plist templates clean
  • plutil -lint on templates after __HOME__/__DOCKER_DIR__ substitution clean
  • mkdocs build --strict succeeds with the new page and nav entry
  • End-to-end verified on my own Mac before scaffolding the templates (prior install used dev.minimal.* label prefix; this PR templatizes to local.* for generic reuse)
  • Fresh-machine install run by a reviewer on a Mac without any existing ~/.distillery/ — confirm the interactive prompt flow, Keychain writes, and that all six agents come up

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Local macOS launchd-based deployment with scheduled polling, classification, rescoring, maintenance, and supervised auto-restart
    • Re-runnable installer and uninstaller with optional data/secret purge, Keychain integration, and readiness checks
    • Local runtime tooling and scripts for run, update, poll, classify, rescore, maintenance, and webhook delivery
  • Documentation

    • New Getting Started guide and README with install, verification, troubleshooting, cadence/secret rotation, and operational workflows

Installs a supervised Distillery HTTP server as a LaunchAgent plus four
scheduled webhook agents (poll every 30 min, classify every 2 h, rescore
daily, maintenance weekly) and a weekly image-update agent. All ingestion
goes through the running server's /api/* surface — DuckDB is single-writer
and the server owns the lock, so sibling CLI processes can't write.

Secrets (JINA_API_KEY, DISTILLERY_WEBHOOK_SECRET) live in the macOS login
Keychain; the webhook secret is generated on first install. The installer
is idempotent and auto-detects OrbStack vs Docker Desktop. Uninstaller
preserves the DB and Keychain entries by default.

Adds a 'Local macOS (launchd)' page under Getting Started with setup,
verification, customization, and troubleshooting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

Adds a launchd-based local macOS deployment for Distillery: installer/uninstaller scripts, Keychain-managed secrets, six supervised LaunchAgents, webhook-invoking maintenance/poll/classify/rescore scripts, Docker-run MCP server templates and configs, docs, and operational/troubleshooting guidance (HTTP at 127.0.0.1:8000/mcp).

Changes

Cohort / File(s) Summary
Documentation
docs/getting-started/local-macos.md, mkdocs.yml, scripts/local-macos/README.md
New getting-started guide, mkdocs nav entry, and README describing install/uninstall, launchd agents, HTTP endpoint, Keychain secrets, schedules, verification and troubleshooting.
Installer / Uninstaller
scripts/local-macos/install.sh, scripts/local-macos/uninstall.sh
Idempotent macOS installer that detects Docker (OrbStack support), manages Keychain (JINA_API_KEY, DISTILLERY_WEBHOOK_SECRET), writes templates/plists, bootstraps agents, optionally kickstarts server; uninstaller removes agents, container, scripts, with flags to purge data and secrets.
LaunchAgent templates
scripts/local-macos/templates/launchd/...
Six launchd plist templates: local.distillery (server), local.distillery-poll, -classify, -rescore, -maintenance, -update with configured schedules, logs and PATH env.
Runtime / Update scripts
scripts/local-macos/templates/run.sh, update.sh
Server runner polls Docker readiness, reads Keychain secrets, manages container lifecycle binding to 127.0.0.1:8000; updater inspects/pulls image, compares digests, logs updates and restarts server when image changes.
Scheduled job scripts
scripts/local-macos/templates/poll.sh, classify.sh, rescore.sh, maintenance.sh
Zsh scripts invoking server webhooks (/api/poll, /api/hooks/classify-batch, /api/rescore, /api/maintenance) via shared helper, with timeouts and accepted HTTP-code handling.
Webhook helper
scripts/local-macos/templates/_webhook_common.sh
Shared helper call_webhook(name, url, timeout_seconds, ok_codes_regex) loads DISTILLERY_WEBHOOK_SECRET from Keychain, posts Bearer-auth requests to SERVER_BASE (http://127.0.0.1:8000), handles timeouts, logs outcomes, and returns exit codes.
Configuration template
scripts/local-macos/templates/distillery.yaml
Local config template using DuckDB at /data/distillery.db, Jina embeddings (jina-embeddings-v3, 1024 dims), auth disabled, webhook secret from env, tagging and feed thresholds.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Installer as install.sh
    participant macOS as "macOS (security, launchctl)"
    participant Docker as "Docker/OrbStack"
    participant Keychain as "Keychain"
    participant FS as "FileSystem (~/.distillery)"
    participant launchctl as "launchctl"

    User->>Installer: ./install.sh [--jina-key / options]
    Installer->>macOS: verify tools (security, launchctl, curl, python3)
    Installer->>Docker: locate docker binary & docker info
    Installer->>Keychain: read/store JINA_API_KEY
    Installer->>Keychain: generate/store DISTILLERY_WEBHOOK_SECRET (if missing)
    Installer->>FS: create ~/.distillery, write distillery.yaml, scripts, plists
    Installer->>launchctl: bootout prior agents
    Installer->>launchctl: bootstrap new LaunchAgents
    Installer->>Docker: kickstart/run server container (via launchd)
    Installer->>User: print paths, logs, example MCP config
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐇
I dug a burrow, scripts in tow,
LaunchAgents hum, the Docker glow,
Secrets snug in Keychain deep,
Webhooks whisper while I sleep,
Local Distillery, hop and go!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: adding a local macOS launchd installer with scheduled pipeline workflows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/local-macos-launchd

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
scripts/local-macos/install.sh (2)

220-239: Add trailing newline at end of file.

The script is missing a trailing newline after line 239.

♻️ Add trailing newline
 Then run /setup inside Claude Code to configure the reporting routines.
 EOF
+
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/local-macos/install.sh` around lines 220 - 239, The file ends the
here-doc (EOF) output without a final newline which can cause POSIX tools and
editors to warn; add a single trailing newline at the end of
scripts/local-macos/install.sh so the last line (the EOF block that prints Data
dir/LaunchAgents/Logs/Uninstall and the JSON snippet) is terminated by a
newline. Locate the here-doc that uses cat <<EOF and the variables DATA_DIR,
LAUNCHAGENTS, and SCRIPT_DIR and ensure the file ends with one final '\n' after
the closing EOF.

30-40: Consider validating --jina-key has an argument.

If a user runs ./install.sh --jina-key without providing a value, $2 will be unset, causing an unclear error due to set -u. Adding a check improves UX.

♻️ Suggested improvement
   case "$1" in
-    --jina-key)       JINA_KEY_CLI="$2"; shift 2 ;;
+    --jina-key)
+      [ $# -lt 2 ] && { echo "--jina-key requires a value" >&2; exit 2; }
+      JINA_KEY_CLI="$2"; shift 2 ;;
     --no-kickstart)   DO_KICKSTART=0; shift ;;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/local-macos/install.sh` around lines 30 - 40, The --jina-key option
handling in the install.sh argument parsing can read an empty $2 (JINA_KEY_CLI)
if the user supplies no value, which breaks with set -u; update the case for
--jina-key in the while/case block to validate that a following argument exists
and is not another flag before assigning to JINA_KEY_CLI, and if missing print a
helpful error and exit non-zero (use the same error channel as other branches),
e.g., check "$#" and that "$2" doesn't start with "-" before shift 2.
scripts/local-macos/uninstall.sh (1)

105-122: Add trailing newline at end of file.

The script is missing a trailing newline after line 122. POSIX conventions expect text files to end with a newline.

♻️ Add trailing newline
 if [ "$PURGE_SECRETS" -eq 0 ]; then
   log "Keychain entries preserved (pass --purge-secrets to delete)"
 fi
+
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/local-macos/uninstall.sh` around lines 105 - 122, The file ends
without a trailing newline (after the final log/prompt block referencing
PURGE_SECRETS, PURGE_DATA, bold and log), violating POSIX text-file conventions;
open the script (uninstall.sh) and add a single newline character at the end of
the file so the last line ends with a newline, then save the file without
altering any other content.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@scripts/local-macos/install.sh`:
- Around line 220-239: The file ends the here-doc (EOF) output without a final
newline which can cause POSIX tools and editors to warn; add a single trailing
newline at the end of scripts/local-macos/install.sh so the last line (the EOF
block that prints Data dir/LaunchAgents/Logs/Uninstall and the JSON snippet) is
terminated by a newline. Locate the here-doc that uses cat <<EOF and the
variables DATA_DIR, LAUNCHAGENTS, and SCRIPT_DIR and ensure the file ends with
one final '\n' after the closing EOF.
- Around line 30-40: The --jina-key option handling in the install.sh argument
parsing can read an empty $2 (JINA_KEY_CLI) if the user supplies no value, which
breaks with set -u; update the case for --jina-key in the while/case block to
validate that a following argument exists and is not another flag before
assigning to JINA_KEY_CLI, and if missing print a helpful error and exit
non-zero (use the same error channel as other branches), e.g., check "$#" and
that "$2" doesn't start with "-" before shift 2.

In `@scripts/local-macos/uninstall.sh`:
- Around line 105-122: The file ends without a trailing newline (after the final
log/prompt block referencing PURGE_SECRETS, PURGE_DATA, bold and log), violating
POSIX text-file conventions; open the script (uninstall.sh) and add a single
newline character at the end of the file so the last line ends with a newline,
then save the file without altering any other content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0bebb0cf-f02d-4d4a-a409-b2dd2a9520df

📥 Commits

Reviewing files that changed from the base of the PR and between 4d0fb64 and 6f05bda.

📒 Files selected for processing (19)
  • docs/getting-started/local-macos.md
  • mkdocs.yml
  • scripts/local-macos/README.md
  • scripts/local-macos/install.sh
  • scripts/local-macos/templates/_webhook_common.sh
  • scripts/local-macos/templates/classify.sh
  • scripts/local-macos/templates/distillery.yaml
  • scripts/local-macos/templates/launchd/local.distillery-classify.plist
  • scripts/local-macos/templates/launchd/local.distillery-maintenance.plist
  • scripts/local-macos/templates/launchd/local.distillery-poll.plist
  • scripts/local-macos/templates/launchd/local.distillery-rescore.plist
  • scripts/local-macos/templates/launchd/local.distillery-update.plist
  • scripts/local-macos/templates/launchd/local.distillery.plist
  • scripts/local-macos/templates/maintenance.sh
  • scripts/local-macos/templates/poll.sh
  • scripts/local-macos/templates/rescore.sh
  • scripts/local-macos/templates/run.sh
  • scripts/local-macos/templates/update.sh
  • scripts/local-macos/uninstall.sh

Previously, `./install.sh --jina-key` (with no following argument)
would silently accept an empty key under set -u or, worse, consume the
next flag. Reject both cases with a clear error and exit 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@norrietaylor
Copy link
Copy Markdown
Owner Author

Addressed CodeRabbit's nitpicks in 1e0ed62:

Fixed

  • install.sh --jina-key now validates an argument is present and isn't another flag; exits 2 with a clear message otherwise.

Skipped (false positive)

  • "Add trailing newline at end of file" on install.sh and uninstall.sh. Verified with tail -c 1 | od -c — both files already end with a single \n. The suggested diff (+) would have added a second blank line, not fixed a missing newline.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/local-macos/install.sh`:
- Around line 63-67: The script currently runs even under sudo/root which causes
per-user LaunchAgents and keychain items to be installed into root's
environment; add a guard (before proceeding with installation, e.g., just after
detecting macOS or at script start) that detects running as root or under sudo
by checking EUID==0 and/or presence of SUDO_USER/SUDO_UID, and if detected print
a clear error (refusing to run as root/sudo) and exit non‑zero; update any
user-facing messages (the existing log "macOS detected" area and the installer
entry point) to abort early when running as root so installs always target the
invoking desktop user.
- Around line 96-100: The docker check using DOCKER and `docker info` only
ensures a daemon is reachable but not that it’s local; add a validation that the
active Docker context/host is local (127.0.0.1 or unix socket) before
proceeding. Specifically, after the existing `if ! "$DOCKER" info` block,
inspect DOCKER_HOST (and if absent use `docker context inspect` / `docker info
--format` to get the endpoint) and fail with a clear message if the host is
remote (eg. starts with ssh:// or tcp:// and is not 127.0.0.1), since later code
assumes a local daemon at http://127.0.0.1:8000. Use the DOCKER variable,
DOCKER_HOST env, and `docker context inspect` to detect non-local contexts and
exit with an error prompting the user to switch to a local context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1c672c19-7390-4ac4-b2ae-e6c0d5916064

📥 Commits

Reviewing files that changed from the base of the PR and between 6f05bda and 1e0ed62.

📒 Files selected for processing (1)
  • scripts/local-macos/install.sh

Comment thread scripts/local-macos/install.sh
Comment thread scripts/local-macos/install.sh Outdated
Two preflight guards in the local-macOS installer:

- Refuse to run as root or under sudo. LaunchAgents and login-Keychain
  items are per-user; running under sudo would install them for root
  instead of the desktop user, producing a silently broken install.

- Reject remote docker contexts (ssh://, tcp://). `docker info` succeeds
  against a remote daemon, but the install binds the server to
  127.0.0.1:8000 on the daemon host — a remote daemon leaves the
  LaunchAgent workers unable to reach the server. Only unix:// contexts
  are accepted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@norrietaylor
Copy link
Copy Markdown
Owner Author

Addressed the two CodeRabbit findings in 37ba75c:

  1. Refuse sudo/root. The installer now aborts if id -u == 0 or SUDO_USER is set, since LaunchAgents and login-Keychain items are per-user and sudo would route them to root.
  2. Reject remote docker contexts. After docker info succeeds, the installer reads docker context inspect --format '{{.Endpoints.docker.Host}}' and bails if the URI isn't unix://. A remote daemon would make the install appear to succeed while the LaunchAgent workers couldn't reach 127.0.0.1:8000 on the wrong host. Verified locally: OrbStack reports unix:///Users/norrie/.orbstack/run/docker.sock and passes.

Unrelated: the failing "Scan (distillery)" check is CVE-2026-3298 (CPython 3.13.13-r2, no upstream fix yet) — a new entry in Grype's 2026-04-23 DB. Main was green yesterday; this PR touches only shell scripts and docs, no Dockerfile or deps. Suppression decision is yours; happy to add one to .grype.yaml if you'd like.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
scripts/local-macos/install.sh (1)

109-117: ⚠️ Potential issue | 🟠 Major

Handle DOCKER_HOST env override in the remote-daemon guard.

Line 109–117 validates the active context host, but Docker can be redirected by DOCKER_HOST. With DOCKER_HOST=ssh://... or tcp://..., Line 101 may pass against a remote daemon and still allow an install that later assumes local loopback behavior.

Suggested patch
 # Reject remote docker contexts (ssh://, tcp://) — the install binds the
 # server to 127.0.0.1:8000 on the docker host, so a remote daemon would
 # produce a "successful" install that the LaunchAgent workers can't reach.
+DOCKER_HOST_ENV="${DOCKER_HOST:-}"
+if [ -n "$DOCKER_HOST_ENV" ] && [[ "$DOCKER_HOST_ENV" != unix://* ]]; then
+  echo "DOCKER_HOST is set to '$DOCKER_HOST_ENV'" >&2
+  echo "  this installer needs a local daemon (unix://) — unset DOCKER_HOST or switch local context" >&2
+  exit 1
+fi
 DOCKER_CONTEXT=$("$DOCKER" context show 2>/dev/null || true)
 DOCKER_HOST_URI=$("$DOCKER" context inspect "$DOCKER_CONTEXT" \
   --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
 if [ -n "$DOCKER_HOST_URI" ] && [[ "$DOCKER_HOST_URI" != unix://* ]]; then
#!/bin/bash
set -euo pipefail

f="scripts/local-macos/install.sh"

echo "== Remote-daemon guard block =="
sed -n '101,120p' "$f"

echo
echo "== Search for explicit DOCKER_HOST env handling =="
rg -n '\$\{DOCKER_HOST:-\}|DOCKER_HOST=' "$f" || true

echo
echo "Expected:"
echo "1) Guard should include an explicit \${DOCKER_HOST:-} check."
echo "2) If absent, remote env overrides can bypass context-only validation."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/local-macos/install.sh` around lines 109 - 117, The remote-daemon
guard uses DOCKER_CONTEXT and DOCKER_HOST_URI but ignores the DOCKER_HOST env
override, allowing DOCKER_HOST=ssh://... or tcp://... to bypass the local-daemon
check; update the guard in scripts/local-macos/install.sh to explicitly check
${DOCKER_HOST:-} (or DOCKER_HOST) and treat any non-empty DOCKER_HOST that is
not a unix:// socket as remote, so combine the existing DOCKER_HOST_URI check
with an explicit DOCKER_HOST condition (using DOCKER_HOST variable name) and
fail with the same explanatory message if DOCKER_HOST points to a non-unix://
endpoint.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@scripts/local-macos/install.sh`:
- Around line 109-117: The remote-daemon guard uses DOCKER_CONTEXT and
DOCKER_HOST_URI but ignores the DOCKER_HOST env override, allowing
DOCKER_HOST=ssh://... or tcp://... to bypass the local-daemon check; update the
guard in scripts/local-macos/install.sh to explicitly check ${DOCKER_HOST:-} (or
DOCKER_HOST) and treat any non-empty DOCKER_HOST that is not a unix:// socket as
remote, so combine the existing DOCKER_HOST_URI check with an explicit
DOCKER_HOST condition (using DOCKER_HOST variable name) and fail with the same
explanatory message if DOCKER_HOST points to a non-unix:// endpoint.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eb44b2f0-51c2-4b41-84db-6f77085d3bce

📥 Commits

Reviewing files that changed from the base of the PR and between 1e0ed62 and 37ba75c.

📒 Files selected for processing (1)
  • scripts/local-macos/install.sh

@norrietaylor norrietaylor merged commit 14e08af into main Apr 24, 2026
11 of 12 checks passed
@norrietaylor norrietaylor deleted the feat/local-macos-launchd branch April 24, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant