Skip to content
C.W.K.
Lesson 02 of 04 · published

Installed by a Kit Template, Placed by the Registry

~13 min · installer, launchd, tailscale-serve, family-registry, sibling-kit

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A file nobody in this repo writes

The service install script in scripts/ opens with a header saying it is generated from a cwkSiblingKit template: edit the kit, then run the kit's sync. So do scripts/family-topology.sh, scripts/configure-tailscale-serve.sh and scripts/kit_family_endpoints.py. Every family engine built on this template installs the same way. Sizzle's own facts ride in as a few template variables that the kit's manifest keeps per repo, such as the app label Sizzle and the health path /api/health for the Serve script, and everything else is read at run time. sibling-kit-quest's vendor-dont-package track explains why these are vendored copies and not a package, and its parameterize-never-fork track explains why per-app facts go into variables instead of edits.

The script is idempotent. Run it after any engine change and it brings the Mac to the state of its checkout: environment, dependencies, web build, launchd job, health, tailnet twin. Mind the word checkout. The script reads the working tree as it finds it and never asks git anything, so an uncommitted edit is deployed exactly like a committed one. Commit and push first if the running engine should match origin.

Refuse the wrong Mac first

Its first move is to ask the registry whether it should run at all. kit_family_endpoints.py runs-here cwkSizzle (the member id is the repository directory's name) exits 0 only where the port ledger, read through this Mac's host profile, places Sizzle. Anywhere else the script says it runs only where the family registry places cwkSizzle, and exits 1 before it touches anything. The gate's logic never names a Mac; the placement it reads lives in the registry. When the registry placed Sizzle on a different Mac on 2026-10-22, the gate's answer followed the ledger.

Then it asks for the network facts. family-topology.sh fetches Sizzle's member card from Firelink's loopback API and prints zsh assignments: KIT_LOOPBACK_URL, KIT_LOOPBACK_PORT, KIT_TAILNET_HTTPS, KIT_HTTPS_PORT, an optional tailnet HTTP URL, the host affinity, and whether that host is this Mac. It refuses outright when the card has no loopback URL with a port, or no tailnet HTTPS URL with a port. The reason sits in its comment, in Dad's words:

하드코딩은 죄악

"Hardcoding is a sin." A port typed into a script is a second copy of a fact, and second copies rot.

The steps, in order

  1. Environment. conda env update --prune from conda-envs/cwk-sizzle.yml: Python 3.12, pip, and the pinned backend/requirements.txt (FastAPI, uvicorn, httpx, Pydantic, Pillow, pillow-heif, pytest). Sizzle has no pyproject.toml, so the script also runs pip install -r backend/requirements.txt.
  2. Web build. npm install and npm run build in frontend/, which writes the frontend/dist the engine mounts.
  3. Render the plist. kit_family_endpoints.py render-plist writes a concrete copy to a temp file, with @@HOME@@, @@DATA_ROOT@@ and any @@PORT:<member>@@ token replaced for this Mac. Every later step reads the rendered copy, never the template. The log directories are created from that copy, because launchd will not create a log's directory, and a job whose log cannot open never starts.
  4. Reload launchd. Read the running job's ExitTimeOut (20 seconds when unset), copy the rendered plist into ~/Library/LaunchAgents, run bootout, then wait up to that timeout plus ten seconds for the old job to disappear. bootout returns before the job is gone, and uvicorn's graceful shutdown waits for requests in flight. Then bootstrap, tried up to three times. The job has RunAtLoad, so the bootstrap has already started it, and no kickstart -k follows. One used to, and it killed the first boot.
  5. Health. Up to fifteen tries, two seconds apart, of curl on /api/health at the loopback URL. A failure names the log paths from the rendered plist.
  6. Tailnet twin. Run configure-tailscale-serve.sh.

The tailnet twin

configure-tailscale-serve.sh repeats the runs-here gate and reads the same topology. It checks the engine's health on loopback first, then runs tailscale serve --bg --yes --https=$KIT_HTTPS_PORT in front of the loopback URL (7601 for Sizzle), and finally fetches /api/health through the tailnet HTTPS address to prove the twin answers. An HTTP twin is added only when the registry declares a tailnet HTTP URL and the engine listens on loopback alone. An engine bound to every interface answers tailnet HTTP itself, and a Serve listener on its port would collide. For Sizzle, teach the HTTPS twin on 7601: the engine binds 127.0.0.1, and whether the registry declares tailnet HTTP for it is not recorded in this repository.

Principle: An installer asks; it does not know. Placement, ports, URLs and data roots come from the registry at run time. Moving a service to another Mac becomes a ledger change, and the same script refuses on the old Mac and converges on the new one.

Code

cwkSizzle, the service install script in scripts/: the placement gate, the topology and the rendered names (verbatim, lines 13-36)·shell
# This script lives in the repository's scripts/; the repository is its member
# id in the family registry, which owns the engine's port and tailnet URLs.
REPO="${0:A:h:h}"
# Only on the Mac the family registry places this member on, including a
# machine's host profile override (cwkPippa todo #549, Pippa's Ark).
/usr/bin/python3 "$REPO/scripts/kit_family_endpoints.py" runs-here "${REPO:t}" || {
  echo "${0:t} runs only where the family registry places ${REPO:t}" >&2
  exit 1
}
topology="$("$REPO/scripts/family-topology.sh" "${REPO:t}")"
eval "$topology"
ENV_NAME="cwk-sizzle"
ENV_BIN="$HOME/miniconda3/envs/$ENV_NAME/bin"
CONDA="$HOME/miniconda3/bin/conda"
LABEL="com.cwk.sizzle-serve"
DOMAIN="gui/$(id -u)"
PLIST_SRC="$REPO/deploy/$LABEL.plist"
PLIST_DST="$HOME/Library/LaunchAgents/$LABEL.plist"
PORT="$KIT_LOOPBACK_PORT"
HEALTH="${KIT_LOOPBACK_URL}/api/health"
# The family data root comes from the registry through the host profile
# (~/cwk-dbs with none, cwkPippa todo #549).
DATA_DIR="$(/usr/bin/python3 "$REPO/scripts/kit_family_endpoints.py" data-root)/sizzle-db"
cd "$REPO"
cwkSizzle, scripts/family-topology.sh: the Python inside the script turns the member card into KIT_ assignments (verbatim, lines 66-89)·python
urls = {key: (value or "").rstrip("/") for key, value in (urls or {}).items()}
loopback, https, http = urls.get("localhost", ""), urls.get("tailscale_https", ""), urls.get("tailscale_http", "")
for label, url in (("loopback URL", loopback), ("tailnet HTTPS URL", https)):
    if not url or urlsplit(url).port is None:
        refuse(f"declares no {label} with a port")
if http and urlsplit(http).port is None:
    refuse("declares a tailnet HTTP URL without a port")
facts = {
    "KIT_LOOPBACK_URL": loopback,
    "KIT_LOOPBACK_PORT": urlsplit(loopback).port,
    "KIT_TAILNET_HTTPS": https,
    "KIT_TAILNET_HOST": urlsplit(https).hostname,
    "KIT_HTTPS_PORT": urlsplit(https).port,
    "KIT_TAILNET_HTTP": http,
    "KIT_TAILNET_HTTP_PORT": urlsplit(http).port if http else "",
    "KIT_HOST_AFFINITY": affinity,
}
try:
    here = (Path.home() / ".config" / "fleet-label").read_text().strip()
except OSError:
    here = ""
facts["KIT_RUNS_HERE"] = "1" if affinity and here and affinity == here else ""
for name, value in facts.items():
    print(f"{name}={shlex.quote(str(value))}")
cwkSizzle, scripts/configure-tailscale-serve.sh: the HTTP twin decision, the HTTPS twin and its proof (verbatim, lines 24-48)·shell
# The registry says whether tailnet HTTP exists; where the engine listens says
# who answers it. An engine on loopback only needs a Serve HTTP twin. One bound
# to every interface answers tailnet HTTP itself, and a Serve listener on its
# port would collide (Bellows, PippaGo, Pulse and QuestKiln bind all interfaces).
# >>> http twin (tests/test_kit_templates.py runs this block alone)
lsof_bin=/usr/sbin/lsof
http_twin=no
if [[ -n "$KIT_TAILNET_HTTP" ]]; then
  listeners="$("$lsof_bin" -nP -iTCP:"$KIT_LOOPBACK_PORT" -sTCP:LISTEN 2>/dev/null \
    | /usr/bin/awk 'NR > 1 && $1 !~ /^([Tt]ailscale|IPNExtens)/ {print $9}' || true)"
  if [[ -n "$listeners" ]] && ! print -r -- "$listeners" | /usr/bin/grep -qvE '^(127\.0\.0\.1|\[::1\]):'; then
    http_twin=yes
  fi
fi
# <<< http twin

# Establish the new secure path before releasing any old same-port HTTPS
# listener; the HTTP route then reclaims the port without an engine rebind.
"$tailscale_bin" serve --bg --yes --https="$KIT_HTTPS_PORT" "$target"
if [[ "$http_twin" == "yes" ]]; then
  "$tailscale_bin" serve --https="$KIT_TAILNET_HTTP_PORT" off >/dev/null 2>&1 || true
  "$tailscale_bin" serve --bg --yes --http="$KIT_TAILNET_HTTP_PORT" "$target"
  /usr/bin/curl --noproxy '*' -fsS --max-time 10 "$KIT_TAILNET_HTTP$health" >/dev/null
fi
/usr/bin/curl --noproxy '*' -fsS --max-time 10 "$KIT_TAILNET_HTTPS$health" >/dev/null

External links

Exercise

Write an install script for a service of your own that is safe to run twice and safe to run on the wrong machine. Make its first step a placement check that exits non-zero anywhere the service should not run. Keep the launchd plist in your repository as a template with a home token, render it into a temp file, and have every later step read the rendered copy. Reload with bootout, wait until the job is gone, then bootstrap. Finish with a health poll that retries and prints the log paths when it gives up.
Hint
Put the placement answer somewhere your script can read at run time (a small JSON file is enough) instead of a hostname in an if. For the wait, loop on launchctl print gui/$(id -u)/your.label until it fails, with a deadline longer than the job's ExitTimeOut.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.