Making the loop outlive the session
The operator starts the station and walks away. If it dies when they log out, they have not walked away - they have to come back, which is the relaying this tool exists to remove.
Why a plain nohup is not enough
station.sh deliberately does not trap HUP. Its cleanup signals the running step's process group, so trapping HUP would kill an in-flight step every time a connection dropped - which on a jump host is often.
So the answer is to start it somewhere the session cannot take with it.
Linux and macOS: service.sh
./service.sh install # survive logout and reboot
./service.sh install --branch task/foo # on a task branch
./service.sh install -- --once # args after -- go to station.sh
./service.sh status
./service.sh logs
./service.sh stop
./service.sh uninstall
It picks the best mechanism available, in this order:
systemd --user, plus lingering | the right answer on any modern Linux. loginctl enable-linger is what makes a user service survive logout |
| launchd | macOS. A LaunchAgent, loaded for real. Needs a bash 4 or newer - see below |
setsid + nohup | the fallback where neither exists. Honest about being one |
HELIOGRAPH_SERVICE_NAME overrides the unit name. One transport repo per investigation is ordinary, and a fixed name would let the second install silently replace the first.
On macOS, the LaunchAgent does not inherit your PATH
It gets /usr/bin:/bin:/usr/sbin:/sbin, and the only bash there is the 3.2 that macOS still ships. The station's preflight refuses 3.2, so a plist that simply says /bin/bash produces a station that fails on every start and is restarted for ever by a KeepAlive doing exactly what it should. Every symptom points at launchd, and launchd is innocent.
service.sh install therefore resolves an absolute path to a bash 4 or newer at install time, writes that into ProgramArguments, and prepends its directory to the LaunchAgent's PATH. With no such bash on the machine it refuses to install rather than leaving a crash loop behind:
error no bash 4 or newer on this Mac, and /bin/bash is 3.2.
brew install bash and run it again.
Windows, with Git for Windows: service.ps1
.\service.ps1 install
.\service.ps1 install --branch task/foo
.\service.ps1 status
.\service.ps1 logs
.\service.ps1 stop
.\service.ps1 uninstall
A scheduled task, not a service. A Windows service needs installation rights and a wrapper for a script; a scheduled task needs neither, runs as the operator, and can be told to run whether that operator is logged on or not - which is the entire requirement.
It registers the task against station.ps1 rather than against a bash path, so the Git-for-Windows discovery stays in one place. See Windows.
Three settings that are not incidental:
ExecutionTimeLimitis zero. The Windows default is three days, after
which the task is stopped - and a loop that quietly stops after three days is precisely the failure this exists to prevent
AtStartup, so it comes back after a reboot with nobody logged in- S4U logon, which runs the task whether the operator is signed in or not
and stores no password. The trade is that it gets no network credentials, which does not matter here: git authenticates with a token from a file or an ssh key, not with the Windows identity
Windows, without bash: the PowerShell payload's own service.ps1
.\service.ps1 install # survive logout and reboot
.\service.ps1 install -- --once # args after -- go to the loop
.\service.ps1 status
.\service.ps1 logs
.\service.ps1 stop
.\service.ps1 uninstall
A different file from the one above, and not interchangeable with it. That one registers the launcher and refuses to install without a start.sh beside it, so on a payload with no bash it refuses every time. Until this shipped, --flavour powershell planted no way to survive a logout at all.
It registers start.ps1 rather than the loop directly, so the preflight runs on every start. A machine that has since had Constrained Language Mode applied, or lost its share mount, refuses and says why instead of starting a loop that cannot work.
The hard part is not the task
A scheduled task starts with a fresh environment and inherits nothing from the shell that registered it. So the transport's variables and any credential simply are not there. The loop then starts, polls happily, captures a perfect log and cannot deliver it - and the far side waits for hours with nothing reporting a fault.
service.ps1 install therefore copies what the station needs into .station-env-ps beside the payload, and start.ps1 reads it before its first check - not just the loop:
That ordering is the whole point, and it was wrong once. The reader lived in station.ps1 alone, so a task installed with TRANSPORT=share ALLOW_ROOT=1 in its config file was refused by the preflight twice over - for being an Administrator, and for running the git transport - and never reached the loop that would have read either. The install reported success, and the table named neither the config file nor the task, so every line of it pointed at the machine. The preflight now prints a config line saying which variables came from the file, because "the task is misconfigured" and "your shell is" are otherwise the same refusal.
The format:
KEY=value, one per line, read and never executed. A.ps1there would
be a file the loop runs at every start, sitting in a directory the far side can write to on some transports. Configuration must not be code.
- Values are verbatim - everything after the first
=. No quoting scheme,
so a token containing a quote, a space or a backslash survives. A newline is refused rather than stripped, because it would forge a second variable and silently changing a credential is worse than not writing it.
- A named list, not the whole environment. Copying everything would put
PATH and TEMP into a file that also holds a token, and make "what is this station configured with" unanswerable.
- The environment wins. Running the station by hand overrides whatever the
service was installed with, so debugging does not start with editing a dotfile. A task has a clean environment, so there the file always applies.
- Read with cmdlets, not .NET.
start.ps1loads it before it has reported
what the language mode is, and [System.Environment] is refused under Constrained Language Mode. A table whose job is to name that policy plainly cannot throw while loading a config file first.
- It holds a token, so its ACL is set to this account only - inheritance
off, inherited rules dropped - which is the Windows equivalent of the bash side's mode 600. uninstall deletes it; leaving a credential behind is not tidying up.
install refuses when a detached loop could not deliver - no SHARE_DIR, no origin remote, no credential at all - because that is the failure nobody sees until hours later. -Force overrides it.
Exit 75 is why the restart policy matters more here
A PowerShell station that updates itself exits 75 rather than re-executing: PowerShell has no exec, and a respawn is killed by the Job Object the cancel depends on. The task is registered with -RestartCount 5 -RestartInterval 1 minute, so that exit is what makes the update take effect. Without it, a self-update stops the station and looks exactly like a station that finished.
service.ps1 status says so when it sees that code, because a non-zero result otherwise reads as a fault.
The credential is where an unattended loop actually fails
A detached process inherits nothing from the shell that started it.
GIT_TOKEN typed before ./station.sh reaches the station
GIT_TOKEN typed before ./service.sh install does NOT reach the service
The loop then starts, polls happily, and cannot deliver a single log - which is discovered hours later by somebody waiting on the far side.
Both installers check for this and refuse rather than installing a loop that cannot deliver. Write the credential to a file the detached process can read:
printf '%s' "$GIT_TOKEN" > ~/.git-token && chmod 600 ~/.git-token
$env:GIT_TOKEN | Out-File -NoNewline -Encoding ascii "$HOME\.git-token"
The capture library reads ~/.git-token already, so nothing else has to change. -Force overrides the refusal if you know better.
A forwarded ssh agent key is the other common trap: it dies with the session, which is exactly when an unattended loop needs it. Use a key on disk, a deploy key, or a token.
Checking it is really running
./service.sh status # what it is doing
./service.sh logs # follow it
On Windows, LastTaskResult of 267009 means "currently running", not an error code. It reads like one to anybody who has not looked it up, so status says so.