CLItrail Docs

Installer hook

The hook is one POSIX shell script. It runs after a successful install, finds CLItrail receipts in local browser storage and reports them with a few coarse facts about the machine. It installs nothing, needs no runtime and never changes your installer’s exit status.

#Get the script

Every install path has its own copy, with the service address, website ID and install path ID written in. Either fetch it at install time:

curl -fsS --connect-timeout 3 --max-time 10 \
  https://YOUR_SERVICE/v1/projects/YOUR_WEBSITE_ID/paths/YOUR_INSTALL_PATH_ID/report.sh \
  2>/dev/null | sh || true

or download clitrail.sh for that path from the Setup tab and ship it with your package. The download contains your public service address, website ID and install path ID, never a secret. The website-level address without /paths/… still works: its installs count toward your first install path. You can read the generic source at /report.sh.

#What it does

  1. Exits immediately, reading nothing, when an opt-out is set or it runs in CI.
  2. Checks its tools, options and service address. Anything missing or invalid ends the run quietly.
  3. Prints one line to stderr when stderr is a terminal, so the person installing knows what runs and how to stop it.
  4. Lists the storage folders of supported browsers for the current user, then scans files of 80 to 4,096 bytes for valid CLItrail markers for your website.
  5. Notes the CPU architecture, the default browser’s family, the time zone and the language (below).
  6. Creates or reuses a random installation ID in its state folder.
  7. Posts one small JSON report to /v1/events, with curl or GNU wget.
  8. Exits with status 0.

The notice reads:

clitrail: linking this install to your recent visit to https://acme.dev (DO_NOT_TRACK=1 turns this off) — https://YOUR_SERVICE/installer

It names your primary website origin, or the website ID when there is none, and links to the service’s installer page, which explains the hook to the people who see it. CLITRAIL_QUIET=1 hides it.

#Options

OptionPurpose
--event NAMEinstall_completed (default), first_run or install_started. See First-run events.
--install-path IDReports toward another install path (ip_…) than the one written in. Wins over CLITRAIL_INSTALL_PATH.
--installation-scope NAMESeparates independent installations of your products on one machine. Default default.
--event-id ID8 to 128 letters, digits, _ or -. Random by default. Reusing an ID makes a retry idempotent.
--handoff TOKENReports a handoff token instead of reading browser storage.
--doctorPrints diagnostics. Sends nothing.
--dry-runScans and prints a JSON summary, such as {"roots":3,"markers":1,"files_checked":1,"truncated":false}. Sends nothing and writes no state.
--state-dir DIRWhere the installation ID is kept.
--root DIRScans only this browser profile or folder instead of discovering them. For development and tests.
--url-id IDAccepts only markers for this page.
--endpoint URL, --project IDOverride the service address and website ID written into the script.

An unknown option or a missing value ends the run silently, so a typo can never fail an install. --doctor says what was wrong.

#Environment variables

VariableEffect
DO_NOT_TRACKAny value except empty, 0 or false turns the hook off. It exits before reading anything.
CLITRAIL_DISABLESame as DO_NOT_TRACK, for CLItrail only.
CI, GITHUB_ACTIONS, GITLAB_CI, BUILDKITE, CIRCLECI, TF_BUILD, JENKINS_URLCI detected: the hook exits before reading anything. CI counts unless it is false or 0; the others count when set at all.
CLITRAIL_ALLOW_CI=1Reports even in CI.
CLITRAIL_QUIET=1Hides the stderr notice.
CLITRAIL_INSTALL_PATHAn install path ID to report toward, instead of the one written into the script.
CLITRAIL_HANDOFFA handoff token, as with --handoff. Set by the handoff command for hooks bundled in your installer.
CLITRAIL_INSTALLATION_SCOPEDefault for --installation-scope.
CLITRAIL_STATE_DIRDefault for --state-dir. Otherwise ~/.local/state/clitrail. Older hooks defaulted to ~/.local/state/opfs-attribution-shell: without this variable or --state-dir, a hook that has no ID yet copies a valid one from there, so an install already reported keeps its installation ID.

#Where it looks

SystemBrowsersFolders
macOSChrome (and Beta, Dev, Canary), Chromium, Edge, Brave, Vivaldi~/Library/Application Support/<browser>/{Default,Profile *}/File System
macOSFirefox~/Library/Application Support/Firefox/Profiles/*/storage/default/*/fs
LinuxChrome (and Beta, Dev), Chromium, Edge, Brave, Vivaldi${XDG_CONFIG_HOME:-~/.config}/<browser>/{Default,Profile *}/File System
LinuxChromium as a Snap or Flatpak~/snap/chromium/common/chromium, ~/.var/app/org.chromium.Chromium/config/chromium
LinuxFirefox, including Firefox 147+ XDG profiles, the Snap and the Flatpak~/.mozilla/firefox, ${XDG_CONFIG_HOME:-~/.config}/mozilla/firefox, ~/snap/firefox/common/.mozilla/firefox, ~/.var/app/org.mozilla.firefox/…, each */storage/default/*/fs
LinuxZen, and its Flatpak~/.zen, ~/.var/app/app.zen_browser.zen/.zen

Safari’s protected storage is never searched; use the handoff command. Arc, and Zen on macOS, publish no profile folder, so they are not searched. Symbolic links in these paths are rejected. On other systems the hook reports nothing unless it is given --root or a handoff token. See the support matrix.

Without --root, the hook looks only in the browsers’ OPFS folders above. There it opens files of 80 to 4,096 bytes, which can include other websites’ small OPFS files, and compares each with CLItrail’s marker format for your website. Anything else is discarded: it is never kept, printed, sent or run as code. It never opens history, cookies, passwords, bookmarks or cache.

#What it sends

{
  "project": "p_04de14934e8005ed97dd",
  "event_id": "6f2da9610246dec311f2b793f7c32902",
  "installation_id": "6e1bb354196915f373a389bb92549519",
  "event_type": "install_completed",
  "receipts": ["5GodFohsqck6lAXWFydmiZTBXvtQnU2WzbpsB8tq-_k"],
  "platform": "darwin",
  "install_path": "ip_4c1f0a9e3b7d2c6e8f10",
  "arch": "arm64",
  "browsers": ["chrome", "firefox"],
  "default_browser": "chrome",
  "timezone": "Europe/Berlin",
  "language": "en-US"
}
FieldWhere it comes from
receiptsThe CLItrail receipts found for your website, at most 64. Empty with a handoff, when handoff carries the token.
platformdarwin or linux from uname -s; absent elsewhere. The raw output is never sent.
install_pathThe install path ID written into the script, or from --install-path or CLITRAIL_INSTALL_PATH. Absent from older scripts.
archarm64 or x86_64 from uname -m. An x86_64 shell on Apple silicon under Rosetta reports arm64, from sysctl.proc_translated.
browsersThe families of supported browsers whose profile folder exists: existence checks only. Sent only when the hook discovered folders itself.
default_browserThe family of the default https handler: on macOS from LaunchServices’ preferences (defaults read; with none set, macOS opens Safari), on Linux from xdg-settings or xdg-mime. Each helper gets at most 1 second; a missing answer leaves the field out.
timezoneAn IANA zone name from TZ, the /etc/localtime link or /etc/timezone.
languageA language tag from the locale (en_US.UTF-8 → en-US), or on macOS the region setting.

Browser families, the default browser, the system and the architecture feed the dashboard’s browser and system breakdowns on every plan. The time zone and language are used only by reconstruction on paid plans. No file paths, user names, host names or command output are sent.

#Run as the right user

Receipts live in the visitor’s browser profile, so the hook must run as that OS user on that computer. If your installer needs root, do the privileged steps with sudo, then run the hook as the original user: a hook running as root finds nothing. For installs that happen elsewhere, such as a remote agent, an SSH session or a container, use the handoff command.

#Requirements and limits

  • Tools. POSIX sh, awk, find, mktemp, date, od, tr, mkdir, cat, rm, rmdir, uname, and curl or GNU wget. No jq, Python, Node or downloaded binary.
  • Service address. https, or http://127.0.0.1, localhost or [::1] with a port for local development.
  • Time and size. Parsing stops after about 4 seconds or 20,000 candidate files, and reports at most 64 receipts. curl waits up to 3 seconds to connect and 5 seconds in total; wget makes one attempt with a 5-second network timeout and follows no redirects.
  • User. The person who visited the website, never root. Remote build machines, package sandboxes and containers usually cannot see that person’s browser.

The script is 29.8 KB of shell. Reporting never fails your install: every problem ends the run with status 0.