Skip to content

Troubleshooting and Gotchas

Rod Christiansen edited this page Oct 8, 2026 · 4 revisions

Troubleshooting and Gotchas

Symptom first. Each entry names what you observed, what causes it, and what to look at. The last section lists behaviours in the current code that surprise people, so you do not spend an afternoon proving one of them to yourself.

Before anything else, know where the evidence is:

/Library/Managed Bootstrap/last-run.json
/Library/Managed Bootstrap/logs/YYYY-MM-DD/HHMMSS/bootstrap.log
/Library/Managed Bootstrap/logs/YYYY-MM-DD/HHMMSS/events.jsonl
/Library/Managed Bootstrap/logs/YYYY-MM-DD/HHMMSS/session.json
/Library/Managed Bootstrap/status.json
/Library/Managed Bootstrap/baseline.json
/Library/Preferences/com.github.bootstrapmate.plist
/tmp/bootstrapmate-postinstall.log
/var/tmp/dialog.log

Start with the one-line summary of the last run:

managedbootstrapinstall --last-run

last-run.json is the most recent run's type, status and per-item results. Each run has one session directory, kept for 30 days and capped at the newest 100: bootstrap.log is the human log, events.jsonl the same records as JSON, and session.json the run's outcome, duration and error count. status.json carries per-phase stage, exit code and last error; baseline.json the last baseline's outcome; /tmp/bootstrapmate-postinstall.log the package install and launchd load evidence; and /var/tmp/dialog.log the SwiftDialog command stream. Logging and Reporting describes the run files.

Symptoms

Nothing ran at all — no log directory, no session directory, no dialog.

Either the package never installed, or the daemon never loaded, or a run already completed and tore the daemon down.

Check /tmp/bootstrapmate-postinstall.log first. It records directory creation, the symlinks, the version check, Loading LaunchDaemon..., and the result of launchctl load -w. If that file does not exist, the package did not install — look at your MDM's command status. On a Mac older than macOS 14 the preinstall refuses the install. pkgutil --pkg-info com.github.bootstrapmate confirms the receipt.

If the postinstall ran, check whether the job is loaded:

launchctl print system/com.github.bootstrapmate

An absent /Library/LaunchDaemons/com.github.bootstrapmate.plist is normal after any completed run — the daemon is one-shot and deletes itself. It is not evidence of failure. Reinstall the package to run again, or invoke the CLI directly. See Deployment.

If the postinstall log says A BootstrapMate run is in progress … not reloading the LaunchDaemon, the install arrived during a run; that run finishes on the old binary and the new one loads on the next install.

If the logs root could not be secured, the CLI writes bootstrapmate: <dir> is missing, not a directory, or writable by an account other than root to stderr. There is no fallback location outside the logs root, so on that path stderr is all you get. A run that reached the logs root but could not create its session directory leaves a flat yyyy-MM-dd-HHmmss.log at the root instead, with no events.jsonl or session.json.

The CLI exits at once with "another run is in progress".

Only one run happens at a time. A second instance — a manual invocation during a daemon run, or the app's Run button — writes bootstrapmate: another run is in progress; exiting to stderr and exits 0. Wait for the live run, which you can follow with --last-run.

The run started but no packages installed.

Usually one of four things.

Exit 1 with No manifest URL configured. Use --jsonurl or configure via management profile. means the configuration profile carrying the com.github.bootstrapmate domain never arrived. The run waits 300 seconds for it, logging Still waiting for management config... (Ns elapsed) every 30 seconds, then Management configuration not received within 300s. Confirm the profile landed with defaults read /Library/Managed\ Preferences/com.github.bootstrapmate. The daemon passes no arguments, so with no profile there is nothing to install.

Preflight script exited 0 means your preflight script decided the device was already configured. That is a deliberate skip: the run type is skip, the daemon is removed, and the CLI exits 0. Check what your script decided; examples/preflight.sh writes its own reasoning to /var/log/bootstrapmate/preflight.log.

Baseline throttle: skipping this baseline: <reason> means the preflight chose baseline (exit 2) and the last baseline was too recent. The reason names the age and the interval. Touch the force file, owned by root, to let the next one through, or lower baselineMinIntervalHours. See Baseline Mode.

Preflight script failed with exit code -1 means a preflight script could not be downloaded or could not be launched at all. That fails the phase, both later phases are skipped, and the session ends as failed. A preceding Could not set executable permission: … warning is often the reason.

An item reported success, or was skipped, but did nothing.

Several mechanisms deliberately skip work.

A package with both packageid and version set is skipped when pkgutil reports an installed receipt at or above that version. If your version string does not compare the way you expect — the comparison is numeric-dot — the item is skipped. Drop version to force the install.

In a baseline run, a package whose hash is in /Library/Managed Bootstrap/installed.json is skipped with this build was already installed by BootstrapMate, and an item with "baseline": false is logged as (excluded from baseline).

A payload already on disk, root-owned and with a matching SHA-256, is not re-downloaded: Already have valid file: <path>. Skipping re-download. If you replaced the file on the server without changing the hash field, the stale local copy wins.

A script with donotwait is recorded as a success when it launches; its result and output are never seen.

The manifest failed to download.

Failed to load manifest from <url>, preceded by the network error — HTTP <code> for <url> for a non-2xx answer — or by Failed to decode BootstrapManifest: …, then exit 1. The fetch has a 60-second timeout.

Check the URL is reachable from the device's network at Setup Assistant time, and that any required Authorization value is set as headers in the profile. If followRedirects is off and the host redirects, the log shows Not following HTTP <code> redirect from <url> to <target>: followRedirects is off. Then check the document itself. Format detection is by URL path extension: .yaml/.yml is parsed as YAML, .json as JSON, and anything else is tried as JSON then YAML. Query parameters do not confuse it.

The most common decode failure is key spelling. Item keys are matched to the Swift property names exactly, so it is skipIf, followRedirects, expectedTeamID — camelCase. skip_if does not work. file, hash, url and type are all required on every item.

Unknown item type: <type> is a typo in type, which accepts only package, rootscript and userscript.

A download, hash or signature check failed.

Download failed: HTTP <code> for <url> means the host refused or could not find the file; a 401 or 403 is called out as a private origin without a valid Authorization header. Fix the URL or the credential.

Hash mismatch or download failed for <path>. Retrying in Ns... repeated, then All retries failed for <path>. The default is 3 attempts, 5 seconds apart, at most 5 attempts and 60 seconds, with the hash re-verified after each. A hash mismatch with no HTTP error means the bytes arrived but are not the ones the manifest names. Fetch the file yourself and compare:

shasum -a 256 /Library/Managed\ Bootstrap/cache/<file>

The hash must be lowercase hex SHA-256.

Refusing to download <path>: <dir> is not a root-owned directory that only root can write means the item's file points into a directory another account owns or can write. Move it under /Library/Managed Bootstrap/cache or another root-only directory.

Discarding cached <path>: it is not a root-owned file that only root can write is a file at the item's path that another account could have written. BootstrapMate deletes it and downloads again; nothing to fix unless it recurs.

Refusing to install <pkg>: untrusted or missing signature (…) means the package failed pkgutil --check-signature and allowUnsigned is not set. Verify it yourself with pkgutil --check-signature <pkg>.

Refusing to install <pkg>: Team ID mismatch — found X, expected Y is never overridable. allowUnsigned does not bypass a Team ID requirement. Fix expectedTeamID — globally, or on the item — or re-sign the package.

Scripts get no signature check at all; only the hash.

The run was cut short by a reboot or shutdown.

A restart sends SIGTERM, and the run closes its session as interrupted. A run killed outright stays running in last-run.json until the next run starts and marks it interrupted, logging Previous run <id> … never finished; recorded as interrupted. An interrupted baseline is retried by the next run regardless of the throttle.

There is no resume inside a run, and the daemon has already been removed if the run got as far as cleanup — so after a reboot nothing re-runs by itself. Reinstall the package or let your MDM's schedule start the next run. What makes a repeat run cheap rather than destructive is the SHA-256 check (no re-download), the pkgutil receipt check (no reinstall), and in a baseline run the install ledger.

If you use --reboot, it only fires when the run succeeded and was not a baseline, five seconds after Reboot requested, triggering in 5 seconds.... A failing run never reboots itself.

The run sits at "Waiting for user to log in...".

The userland stage polls every two seconds for a console user that is not loginwindow, _mbsetupuser or root, and does not begin with an underscore, for up to userlandLoginTimeout seconds (default 3600). After that it logs No user logged in after <n>s - giving up on the userland stage, records the stage as skipped and finishes. If it waits longer than an hour, check whether userlandLoginTimeout is set to 0 in the profile.

A user script failed with "No console user".

userscript items run as the console user and never fall back to root. With --userscript and nobody logged in, each script fails with Cannot run <name> as a user: no console user is logged in.

The settings app says "Helper not available".

The app could not reach the privileged helper. The helper accepts only a client signed with its own Team ID, so an unsigned or ad-hoc signed build — including the unsigned package from this repository's releases — refuses every connection. Sign the bundle. If it is signed, check that the helper is loaded:

launchctl print system/com.github.bootstrapmate.helper

The CLI and the daemon-driven run do not use the helper and are unaffected. See Managed Bootstrap Install App.

A setting in the app is greyed out with a Managed label.

A configuration profile forces that key. The app shows the profile's value and the helper refuses to overwrite it. Change it in the profile.

Nothing appears on screen during a run.

The window opens only on a provisioning run: skip and baseline runs never show one. On a provisioning run, BootstrapMate looks for SwiftDialog at /usr/local/bin/dialog once, when the dialog code first loads. If it is not there it runs headless and every dialog call becomes a no-op; bootstrap.log carries the DEBUG line SwiftDialog not found at /usr/local/bin/dialog - running in headless mode. Installing SwiftDialog later in the same run does not help.

The dialog is also disabled by enableDialog set to false, --no-dialog or --silent.

Known issues

Each of these is present in the current code. The observable consequence is stated; no fix or timeline is implied.

Five boolean preferences are overridden by the CLI on every run. dryRun, reboot, userscriptOnly, silentMode and verboseMode are read from the profile, but the CLI then writes its own flag value for each of them whether or not the flag was passed. Consequence: on the LaunchDaemon-driven run, which passes no flags, all five are false whatever the profile says. Use --reboot, --dry-run, --userscript, --silent and --verbose instead. Runs started from the settings app pass --reboot, --silent and --dry-run from its toggles and always pass --verbose, but never --userscript.

The per-phase status plist is replaced on success. After a successful run, /Library/Preferences/com.github.bootstrapmate.plist is overwritten with only LastRunVersion, LastUpdated and Architecture. Consequence: per-phase Stage, ExitCode and LastError are unavailable there after a success — you have status.json, last-run.json and the run's session directory instead. A failed run leaves the full per-phase detail in place.

Status writes replace locally saved preferences. The status plist, /Library/Preferences/com.github.bootstrapmate.plist, is the same file the settings app's helper writes machine-level preferences to. Each status write rewrites the whole file with only the per-phase entries (or, on success, the three keys above), dropping any top-level preference keys it held. Consequence: settings saved from the Prefs tab or with defaults write to that file do not survive a run. Values delivered by a configuration profile live under /Library/Managed Preferences and are unaffected, so deliver anything a run depends on by profile.

A successful run reports empty phases. The plist overwrite above happens before the reporting POST, and the report reads its phase data from the plist. Consequence: your reporting endpoint receives phases: {} for successful runs and full per-phase detail for failures.

Downloads are held entirely in memory. Payloads are fetched into memory and written directly to file, deliberately, because the system temp directory is read-only during Setup Assistant. Consequence: a large package is fully resident in RAM while it downloads.

examples/preflight.sh never returns 2. Its header documents the baseline exit code, but the script only exits 0 or 1. Consequence: deploying it as-is never produces a baseline run; add the branch yourself.

See also

Clone this wiki locally