Skip to content

How It Works

Rod Christiansen edited this page Sep 3, 2026 · 2 revisions

How It Works

This page is the order of events in a single BootstrapMate run, with the trigger and the security context of each step. Read it when a run did something you did not expect and you need to know which step you are looking at in the log.

Everything below happens in one process, running as root, from start to exit. There is no second process, no LaunchAgent and no user-context worker.

1. Invocation

Three things start the CLI. The package's postinstall boots out and then loads /Library/LaunchDaemons/com.github.bootstrapmate.plist, whose RunAtLoad starts a run immediately as root — this is the Setup Assistant path, and the daemon passes no arguments, so the configuration must already be in the com.github.bootstrapmate managed-preferences domain. An administrator can run the binary by hand with flags. The GUI can start one via the root XPC helper, which adds --verbose to the arguments it was given.

2. Startup, logging and signals

The CLI creates /Library/Managed Bootstrap/logs if it is missing. If that fails it writes one line to stderr and carries on with no log file — there is no fallback log location. It then opens a new log file named for the current time, deletes any .log in that directory older than 30 days, installs a SIGTERM handler (which kills SwiftDialog and exits 1, the path the GUI's Stop button uses), and logs the session header and the full command line.

3. Waiting for the machine to be ready

Three waits follow, in this order, and none of them is fatal:

  • Network. An NWPathMonitor waits up to --network-timeout seconds (default 120) for a satisfied path. On timeout it does one synchronous DNS resolution of apple.com as a last check, then logs either Network is available or Network check timed out - proceeding anyway and continues either way. A run that proceeds without a network will fail its downloads. Note that the networkTimeout managed preference is read but never applied — only the CLI option takes effect.
  • Data volume. If /Library/Managed Bootstrap is not writable — which happens early in Setup Assistant — it polls /Library once a second for up to 30 seconds before proceeding regardless.
  • Management profile. Only when no --jsonurl was passed and the configuration is not yet valid, it reloads managed preferences once a second for up to 300 seconds, logging progress every 30. On expiry it logs Management configuration not received within 300s and continues, which then produces No manifest URL configured… and exit 1.

4. Configuration and manifest fetch

CLI arguments are applied over the managed preferences, which are applied over the embedded defaults; with --verbose the effective configuration is printed. The authorization header, if configured, is set globally, so it is sent both on the manifest fetch and on every subsequent item download.

The manifest is then downloaded, with a 60-second ceiling, and decoded — YAML for a .yaml/.yml path, JSON for .json, and JSON-then-YAML for anything else. A download or decode failure ends the run with exit 1 before any stage begins.

If --userscript was passed, the run short-circuits here: only userland items of type userscript are executed, a session summary is written, and the process exits 0 regardless of what those scripts returned.

5. Stage ordering

Stages always run preflightsetupassistantuserland. There is no way to reorder them and no dependency graph inside a stage; items run in the order you wrote them.

Preflight uses only the first item whose type is rootscript; any others in the array are ignored. Its exit code decides the run: 0 means the Mac is already provisioned, so the whole bootstrap is skipped, cleanup runs and the CLI exits 0; greater than 0 means continue; a negative code means the script could not be downloaded or launched, which fails the phase and skips both remaining stages.

Setup Assistant runs every item in setupassistant. A failing item is recorded and the loop continues — a bad package must not strand provisioning — but the run is marked failed.

Userland first sets its status to Starting, then blocks in a two-second poll loop until the console user is a real user, ignoring loginwindow, _mbsetupuser, root and any name starting with an underscore. This wait has no timeout. A Mac that never reaches a login, or that sits at a login window nobody uses, stays in this loop indefinitely with the dialog reading "Waiting for user to log in...". Once a user appears, the items run.

Setup Assistant versus after login

The distinction is entirely about when in the same run an item executes, not about who executes it. preflight and setupassistant items run while Setup Assistant is still up and no user session exists. userland items run in the same root daemon process after a console user appears. userscript items are executed the same way rootscript items are — as root — because the helper that would run a script as the console user is present in the source but never called. If an item genuinely needs the user's context, it has to establish that itself.

6. Per-item work

For each item, in order: the skipIf architecture test can skip it outright; then the file is downloaded to the absolute path in file, retries times (default 3) with retrywait seconds between attempts (default 5), re-checking the SHA-256 after each; an existing file whose hash already matches is not downloaded again. Downloads are read into memory and written non-atomically, because the system temp directory is read-only during Setup Assistant, so a very large payload is fully resident in RAM. HTTP status codes are not inspected, so an error page reaches the destination and then fails the hash check.

A package with both packageid and version set is skipped if pkgutil already reports that receipt at that version or newer. Otherwise its signature is checked with pkgutil --check-signature before installer runs, and it is refused if untrusted (unless allowUnsigned) or if its Team ID does not match expectedTeamID (which no setting overrides). Scripts get no signature check — only the hash. A script with donotwait is launched and reported as success immediately, without waiting for it.

There is no retry on an installer failure or a script failure; retries apply to downloads only.

7. Status, dialog and reporting

Throughout the run, per-phase status (stage, start and completion time, exit code, last error, run id) is written to /Library/Managed Bootstrap/status.json and to /Library/Preferences/com.github.bootstrapmate.plist, and, when SwiftDialog is present at /usr/local/bin/dialog and neither --no-dialog nor --silent was given, each item's state is pushed to the on-screen progress list.

At the end, the dialog closes and — only if a reporting URL is configured — a single JSON run summary is POSTed, with a 15-second request timeout. A reporting failure is logged and never fails the run. Be aware that a successful run overwrites the status plist with just LastRunVersion, LastUpdated and Architecture before the summary is built, so a successful run reports an empty phases block while a failing one reports full detail.

8. Cleanup and exit

Cleanup runs at the end of every run — success, failure, or preflight skip. It deletes /Library/LaunchDaemons/com.github.bootstrapmate.plist and then runs launchctl bootout system/com.github.bootstrapmate, falling back to launchctl remove if that fails. Downloaded payloads are not removed; they stay wherever each item's file put them.

If --reboot (or the reboot preference) is set and the run succeeded, a reboot is triggered five seconds later. The process then exits 0 if every stage succeeded — or if preflight chose to skip — and 1 if the manifest could not be loaded, no manifest URL was configured, a stage failed, or SIGTERM was received.

No resume

There is no state machine and no resume. Every invocation starts from scratch with a fresh run id. The only things that make a repeat run cheaper are the SHA-256 check that skips a re-download, the pkgutil receipt check that skips a reinstall, and whatever logic you put in your own preflight script. Because the daemon deletes itself during cleanup, a second unattended run does not happen on its own.

See also

Clone this wiki locally