diff --git a/apps/web/public/_headers b/apps/web/public/_headers
index e8c1172..47b6c69 100644
--- a/apps/web/public/_headers
+++ b/apps/web/public/_headers
@@ -10,3 +10,13 @@
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
+
+# One-line CLI installers (curl … | sh, irm … | iex): plain text, never
+# cached long, so a fixed installer reaches everyone at once.
+/install.sh
+ Content-Type: text/plain; charset=utf-8
+ Cache-Control: public, max-age=300
+
+/install.ps1
+ Content-Type: text/plain; charset=utf-8
+ Cache-Control: public, max-age=300
diff --git a/apps/web/public/install.ps1 b/apps/web/public/install.ps1
new file mode 100644
index 0000000..7d08f34
--- /dev/null
+++ b/apps/web/public/install.ps1
@@ -0,0 +1,220 @@
+# Install the BridgeHook CLI (Windows).
+#
+# irm https://bridgehook.dev/install.ps1 | iex
+#
+# Downloads bridgehook.exe from the newest stable CLI release on GitHub
+# (tags cli-v), checks that the download is intact against the
+# release's SHA256SUMS, installs it to %LOCALAPPDATA%\bridgehook\bin and adds
+# that folder to your user PATH (no administrator rights). Running it again
+# upgrades in place. Nothing is installed unless the checksum matches.
+# Works in Windows PowerShell 5.1 and PowerShell 7.
+#
+# $env:BRIDGEHOOK_VERSION = '0.1.0' a specific version
+# $env:BRIDGEHOOK_INSTALL_DIR = 'C:\...' where to install
+# $env:BRIDGEHOOK_DOWNLOAD_URL = 'https://...' a directory holding the
+# binaries and SHA256SUMS (mirrors)
+#
+# macOS and Linux: curl -fsSL https://bridgehook.dev/install.sh | sh
+
+& {
+ $ErrorActionPreference = 'Stop'
+ Set-StrictMode -Version 3.0
+
+ $Repo = 'hallelx2/bridgehook'
+ # Overridable only so tests can stand in for GitHub.
+ $GitHub = if ($env:BRIDGEHOOK_GITHUB_URL) { $env:BRIDGEHOOK_GITHUB_URL.TrimEnd('/') } else { 'https://github.com' }
+ $GitHubApi = if ($env:BRIDGEHOOK_GITHUB_API_URL) { $env:BRIDGEHOOK_GITHUB_API_URL.TrimEnd('/') } else { 'https://api.github.com' }
+
+ # Messages for the person watching: Write-Output would land in the
+ # pipeline of `irm | iex`, and Write-Information is hidden by default.
+ function Say {
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
+ param([string]$Message = '')
+ Write-Host $Message
+ }
+
+ function Fail([string]$Message) {
+ throw "bridgehook install: $Message"
+ }
+
+ # The newest stable CLI release. CLI releases are tagged cli-v;
+ # the repository has other releases too, and prereleases are never
+ # picked. The API lists newest first and leaves out drafts.
+ function Get-NewestCliVersion {
+ try {
+ $releases = Invoke-RestMethod -UseBasicParsing -Uri "$GitHubApi/repos/$Repo/releases?per_page=50" -Headers @{ 'User-Agent' = 'bridgehook-install' }
+ # Windows PowerShell 5.1 passes a JSON array down the pipeline as
+ # one object, so walk it explicitly.
+ foreach ($release in @($releases)) {
+ if ($release.tag_name -like 'cli-v[0-9]*' -and -not $release.prerelease) {
+ return $release.tag_name.Substring(5)
+ }
+ }
+ } catch {
+ $null = $_ # fall through to the redirect below
+ }
+ # The API failed (60 requests an hour per IP; offices and CI hit it) or
+ # listed no CLI release: GitHub's latest-release redirect is not limited
+ # the same way. The publish script marks CLI releases latest.
+ $tag = Get-LatestRedirectTag
+ if ($tag -like 'cli-v[0-9]*') { return $tag.Substring(5) }
+ Fail "cannot find the newest CLI release (GitHub's API may be rate-limiting this network). Set `$env:BRIDGEHOOK_VERSION = '0.1.0' (for example) and run again, or see https://github.com/$Repo/releases"
+ }
+
+ # The tag GitHub's /releases/latest redirects to, or $null.
+ function Get-LatestRedirectTag {
+ try {
+ $request = [Net.HttpWebRequest]::Create("$GitHub/$Repo/releases/latest")
+ $request.AllowAutoRedirect = $false
+ $request.Method = 'HEAD'
+ $request.UserAgent = 'bridgehook-install'
+ $response = $request.GetResponse()
+ try {
+ $location = $response.Headers['Location']
+ } finally {
+ $response.Close()
+ }
+ if ($location -match '/tag/([A-Za-z0-9._+-]+)$') { return $Matches[1] }
+ } catch {
+ $null = $_
+ }
+ return $null
+ }
+
+ # Adds a folder to the user PATH (HKCU\Environment, no administrator
+ # rights). The value is read and written raw, as REG_EXPAND_SZ, so entries
+ # such as %USERPROFILE%\bin stay references instead of being frozen to
+ # today's expansion; open programs are then told the environment changed.
+ function Add-UserPath([string]$Dir) {
+ $key = Get-Item -LiteralPath 'HKCU:\Environment'
+ $raw = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
+ $entries = @($raw -split ';' | Where-Object { $_ })
+ $present = $entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\') -ieq $Dir.TrimEnd('\') }
+ if ($present) { return $false }
+ Set-ItemProperty -LiteralPath 'HKCU:\Environment' -Name 'Path' -Type ExpandString -Value (($entries + $Dir) -join ';')
+ Send-EnvironmentChanged
+ return $true
+ }
+
+ # WM_SETTINGCHANGE "Environment", as setx and the System settings send.
+ function Send-EnvironmentChanged {
+ try {
+ if (-not ('BridgeHookInstall.Native' -as [type])) {
+ Add-Type -Namespace BridgeHookInstall -Name Native -MemberDefinition @'
+[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
+'@
+ }
+ $result = [UIntPtr]::Zero
+ [void][BridgeHookInstall.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result)
+ } catch {
+ $null = $_ # new terminals still read the registry
+ }
+ }
+
+ # Windows PowerShell 5.1 may default to TLS 1.0, which GitHub refuses.
+ if ($PSVersionTable.PSVersion.Major -lt 6) {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+ }
+
+ $onWindows = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows
+
+ # One build for now: x64, which Windows on ARM runs under emulation.
+ $target = 'x86_64-pc-windows-gnu'
+ if ($env:BRIDGEHOOK_TARGET) { $target = $env:BRIDGEHOOK_TARGET }
+ $asset = "bridgehook-$target.exe"
+
+ if ($env:BRIDGEHOOK_INSTALL_DIR) {
+ $installDir = $env:BRIDGEHOOK_INSTALL_DIR
+ } elseif ($onWindows) {
+ $installDir = Join-Path $env:LOCALAPPDATA 'bridgehook\bin'
+ } else {
+ Fail 'this script is for Windows; on macOS and Linux run: curl -fsSL https://bridgehook.dev/install.sh | sh'
+ }
+
+ if ($env:BRIDGEHOOK_DOWNLOAD_URL) {
+ $base = $env:BRIDGEHOOK_DOWNLOAD_URL.TrimEnd('/')
+ $version = if ($env:BRIDGEHOOK_VERSION) { $env:BRIDGEHOOK_VERSION } else { 'unknown' }
+ } else {
+ if ($env:BRIDGEHOOK_VERSION) {
+ $version = $env:BRIDGEHOOK_VERSION.TrimStart('v')
+ } else {
+ $version = Get-NewestCliVersion
+ }
+ $base = "$GitHub/$Repo/releases/download/cli-v$version"
+ }
+
+ Say "Installing bridgehook $version for $target"
+
+ $tmp = Join-Path ([IO.Path]::GetTempPath()) ("bridgehook-" + [Guid]::NewGuid().ToString('N'))
+ New-Item -ItemType Directory -Path $tmp | Out-Null
+ try {
+ $exe = Join-Path $tmp $asset
+ $sums = Join-Path $tmp 'SHA256SUMS'
+ # The progress bar makes Invoke-WebRequest many times slower in 5.1.
+ $oldProgress = $ProgressPreference
+ $ProgressPreference = 'SilentlyContinue'
+ try {
+ Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile $exe
+ Invoke-WebRequest -UseBasicParsing -Uri "$base/SHA256SUMS" -OutFile $sums
+ } catch {
+ Fail "download failed from ${base}: $($_.Exception.Message)"
+ } finally {
+ $ProgressPreference = $oldProgress
+ }
+
+ $expected = $null
+ foreach ($line in Get-Content -LiteralPath $sums) {
+ $parts = $line -split '\s+', 2
+ if ($parts.Count -eq 2 -and ($parts[1] -eq $asset -or $parts[1] -eq "*$asset")) {
+ $expected = $parts[0].ToLowerInvariant()
+ break
+ }
+ }
+ if (-not $expected) { Fail "$asset is not listed in SHA256SUMS; not installing" }
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $exe).Hash.ToLowerInvariant()
+ if ($actual -ne $expected) {
+ Fail "checksum mismatch for $asset (expected $expected, got $actual); not installing"
+ }
+
+ New-Item -ItemType Directory -Force -Path $installDir | Out-Null
+ $dest = Join-Path $installDir 'bridgehook.exe'
+ $staged = Join-Path $installDir 'bridgehook.exe.new'
+ Copy-Item -LiteralPath $exe -Destination $staged -Force
+ if (Test-Path -LiteralPath $dest) {
+ # A running bridgehook.exe cannot be overwritten, but it can be
+ # renamed; the old copy is removed on the next install.
+ $old = Join-Path $installDir 'bridgehook.exe.old'
+ Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue
+ Move-Item -LiteralPath $dest -Destination $old -Force
+ }
+ Move-Item -LiteralPath $staged -Destination $dest -Force
+ Remove-Item -LiteralPath (Join-Path $installDir 'bridgehook.exe.old') -Force -ErrorAction SilentlyContinue
+ } finally {
+ Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ $installed = $null
+ if ($onWindows) {
+ try { $installed = & $dest --version } catch { $installed = $null }
+ }
+ if ($installed) {
+ Say "Installed $installed to $dest"
+ } else {
+ Say "Installed bridgehook to $dest"
+ }
+
+ # A custom install directory is left off PATH unless asked for.
+ $addToPath = $onWindows -and ((-not $env:BRIDGEHOOK_INSTALL_DIR) -or $env:BRIDGEHOOK_ADD_TO_PATH)
+ if ($addToPath) {
+ if (Add-UserPath $installDir) {
+ Say "Added $installDir to your user PATH; open a new terminal to use bridgehook."
+ }
+ if (-not (($env:Path -split ';') | Where-Object { $_.TrimEnd('\') -ieq $installDir.TrimEnd('\') })) {
+ $env:Path = "$env:Path;$installDir"
+ }
+ }
+
+ Say
+ Say 'Next: bridgehook login, then bridgehook forward 3000'
+}
diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh
new file mode 100644
index 0000000..57337e1
--- /dev/null
+++ b/apps/web/public/install.sh
@@ -0,0 +1,219 @@
+#!/bin/sh
+# Install the BridgeHook CLI (macOS, Linux).
+#
+# curl -fsSL https://bridgehook.dev/install.sh | sh
+#
+# Downloads the `bridgehook` binary for this OS and CPU from the newest
+# stable CLI release on GitHub (tags `cli-v`), checks that the
+# download is intact against the release's SHA256SUMS, and installs it to
+# ~/.local/bin. Running it again upgrades in place. Nothing is installed
+# unless the checksum matches.
+#
+# BRIDGEHOOK_VERSION=0.1.0 a specific version instead of the newest
+# BRIDGEHOOK_INSTALL_DIR=/path where to install (default ~/.local/bin)
+# BRIDGEHOOK_DOWNLOAD_URL=url a directory holding the binaries and
+# SHA256SUMS (mirrors)
+#
+# Windows: irm https://bridgehook.dev/install.ps1 | iex
+
+set -eu
+
+REPO="hallelx2/bridgehook"
+# Overridable only so tests can stand in for GitHub.
+GITHUB=${BRIDGEHOOK_GITHUB_URL:-https://github.com}
+GITHUB_API=${BRIDGEHOOK_GITHUB_API_URL:-https://api.github.com}
+
+say() { printf '%s\n' "$*"; }
+err() {
+ printf 'bridgehook install: %s\n' "$*" >&2
+ exit 1
+}
+
+# Everything runs inside main, so a download cut short mid-script (curl | sh)
+# runs nothing.
+main() {
+ need uname
+ need mktemp
+ need chmod
+ need mkdir
+ need mv
+
+ target=$(detect_target)
+ install_dir=${BRIDGEHOOK_INSTALL_DIR:-$HOME/.local/bin}
+
+ if [ -n "${BRIDGEHOOK_DOWNLOAD_URL:-}" ]; then
+ base=${BRIDGEHOOK_DOWNLOAD_URL%/}
+ version=${BRIDGEHOOK_VERSION:-unknown}
+ else
+ version=$(resolve_version)
+ base="$GITHUB/$REPO/releases/download/cli-v$version"
+ fi
+
+ asset="bridgehook-$target"
+ tmp=$(mktemp -d 2>/dev/null || mktemp -d -t bridgehook)
+ # EXIT cleans up on every path; INT and TERM must also stop the script,
+ # which a bare cleanup handler does not do in dash or busybox sh.
+ trap 'rm -rf "$tmp"' EXIT
+ trap 'rm -rf "$tmp"; exit 130' INT TERM
+
+ say "Installing bridgehook ${version} for ${target}"
+ download "$base/$asset" "$tmp/$asset"
+ download "$base/SHA256SUMS" "$tmp/SHA256SUMS"
+ verify "$tmp" "$asset"
+
+ mkdir -p "$install_dir" || err "cannot create $install_dir"
+ chmod 755 "$tmp/$asset"
+ # Move within the install directory so the swap is a rename: an existing
+ # bridgehook is replaced in one step, never left half written.
+ staged="$install_dir/.bridgehook.new.$$"
+ mv "$tmp/$asset" "$staged" 2>/dev/null || { cp "$tmp/$asset" "$staged" && chmod 755 "$staged"; } ||
+ err "cannot write to $install_dir (set BRIDGEHOOK_INSTALL_DIR to a directory you own)"
+ mv -f "$staged" "$install_dir/bridgehook" || {
+ rm -f "$staged"
+ err "cannot replace $install_dir/bridgehook"
+ }
+
+ installed=$("$install_dir/bridgehook" --version 2>/dev/null || true)
+ say "Installed ${installed:-bridgehook} to $install_dir/bridgehook"
+
+ case ":$PATH:" in
+ *":$install_dir:"*) ;;
+ *)
+ say ""
+ say "$install_dir is not on your PATH. Add it, then open a new terminal:"
+ case ${SHELL:-} in
+ */fish) say " fish_add_path $install_dir" ;;
+ *) say " echo 'export PATH=\"$install_dir:\$PATH\"' >> $(profile_file)" ;;
+ esac
+ ;;
+ esac
+ say ""
+ say "Next: bridgehook login, then bridgehook forward 3000"
+}
+
+need() {
+ command -v "$1" >/dev/null 2>&1 || err "needs '$1', which is not installed"
+}
+
+detect_target() {
+ os=$(uname -s)
+ arch=$(uname -m)
+ case $os in
+ Linux) os_part=unknown-linux-musl ;;
+ Darwin) os_part=apple-darwin ;;
+ MINGW* | MSYS* | CYGWIN* | Windows_NT)
+ err "on Windows, run in PowerShell: irm https://bridgehook.dev/install.ps1 | iex" ;;
+ *) err "no build for $os yet; see https://github.com/$REPO/releases" ;;
+ esac
+ case $arch in
+ x86_64 | amd64) arch_part=x86_64 ;;
+ aarch64 | arm64) arch_part=aarch64 ;;
+ *) err "no build for $os on $arch yet; see https://github.com/$REPO/releases" ;;
+ esac
+ # An Intel shell under Rosetta on Apple silicon: install the native build.
+ if [ "$os" = Darwin ] && [ "$arch_part" = x86_64 ] &&
+ [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then
+ arch_part=aarch64
+ fi
+ printf '%s-%s' "$arch_part" "$os_part"
+}
+
+# The newest stable CLI release, or BRIDGEHOOK_VERSION. CLI releases are
+# tagged cli-v; the repository has other releases too, and
+# prereleases (cli-v0.2.0-rc1) are never picked.
+resolve_version() {
+ if [ -n "${BRIDGEHOOK_VERSION:-}" ]; then
+ printf '%s' "${BRIDGEHOOK_VERSION#v}"
+ return
+ fi
+ if json=$(fetch_text "$GITHUB_API/repos/$REPO/releases?per_page=50" 2>/dev/null); then
+ v=$(printf '%s\n' "$json" | newest_stable_cli)
+ [ -n "$v" ] && { printf '%s' "$v"; return; }
+ fi
+ # The API failed (it allows 60 requests an hour per IP, so offices and CI
+ # hit it) or listed no CLI release: GitHub's latest-release redirect is
+ # not limited the same way. The publish script marks CLI releases latest.
+ tag=$(latest_redirect_tag)
+ case $tag in
+ cli-v[0-9]*) printf '%s' "${tag#cli-v}" ;;
+ *) err "cannot find the newest CLI release (GitHub's API may be rate-limiting this network). Set BRIDGEHOOK_VERSION, e.g. BRIDGEHOOK_VERSION=0.1.0, or see https://github.com/$REPO/releases" ;;
+ esac
+}
+
+# Reads the releases API's JSON on stdin and prints the newest cli-v version
+# that is not a prerelease. Splitting on commas puts each field on its own
+# line whether the JSON is pretty-printed or not; within a release,
+# "tag_name" comes before "prerelease". The API lists newest first and
+# leaves out drafts.
+newest_stable_cli() {
+ tr ',' '\n' | awk '
+ /"tag_name"[[:space:]]*:/ {
+ tag = $0
+ sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag)
+ sub(/".*/, "", tag)
+ }
+ /"prerelease"[[:space:]]*:/ {
+ if (tag ~ /^cli-v[0-9]/ && $0 ~ /false/) { sub(/^cli-v/, "", tag); print tag; exit }
+ tag = ""
+ }'
+}
+
+# The tag GitHub's /releases/latest redirects to, or nothing.
+latest_redirect_tag() {
+ url="$GITHUB/$REPO/releases/latest"
+ if command -v curl >/dev/null 2>&1; then
+ loc=$(curl -fsSI -o /dev/null -w '%{redirect_url}' "$url" 2>/dev/null || true)
+ elif command -v wget >/dev/null 2>&1; then
+ loc=$(wget -S --max-redirect=0 -O /dev/null "$url" 2>&1 | sed -n 's/^ *[Ll]ocation: *//p' | tr -d '\r' | tail -n 1)
+ else
+ loc=
+ fi
+ printf '%s' "${loc##*/tag/}" | sed 's/[^A-Za-z0-9._+-].*//'
+}
+
+fetch_text() {
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL --retry 3 "$1"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO- "$1"
+ else
+ err "needs curl or wget"
+ fi
+}
+
+download() {
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL --retry 3 -o "$2" "$1" || err "download failed: $1"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "$2" "$1" || err "download failed: $1"
+ else
+ err "needs curl or wget"
+ fi
+}
+
+verify() {
+ dir=$1
+ file=$2
+ expected=$(awk -v f="$file" '$2 == f || $2 == "*" f { print $1; exit }' "$dir/SHA256SUMS")
+ [ -n "$expected" ] || err "$file is not listed in SHA256SUMS; not installing"
+ if command -v sha256sum >/dev/null 2>&1; then
+ actual=$(sha256sum "$dir/$file" | awk '{ print $1 }')
+ elif command -v shasum >/dev/null 2>&1; then
+ actual=$(shasum -a 256 "$dir/$file" | awk '{ print $1 }')
+ elif command -v openssl >/dev/null 2>&1; then
+ actual=$(openssl dgst -sha256 "$dir/$file" | awk '{ print $NF }')
+ else
+ err "needs sha256sum, shasum or openssl to check the download"
+ fi
+ [ "$actual" = "$expected" ] || err "checksum mismatch for $file (expected $expected, got $actual); not installing"
+}
+
+profile_file() {
+ case ${SHELL:-} in
+ */zsh) printf '%s' "$HOME/.zshrc" ;;
+ */bash) printf '%s' "$HOME/.bashrc" ;;
+ *) printf '%s' "$HOME/.profile" ;;
+ esac
+}
+
+main "$@"
diff --git a/cli/README.md b/cli/README.md
index afe8554..3b0a2a9 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -2,6 +2,20 @@
A stable public webhook URL that forwards to a local port. Webhooks sent while nothing is forwarding wait on the relay, and the next `bridgehook forward` delivers them in order.
+## Install
+
+```sh
+curl -fsSL https://bridgehook.dev/install.sh | sh # macOS, Linux (x86_64, arm64)
+```
+
+```powershell
+irm https://bridgehook.dev/install.ps1 | iex # Windows
+```
+
+Both install the newest stable `cli-v*` release from GitHub (never a prerelease) after checking that the download is intact against the release's `SHA256SUMS`, and install nothing if it does not match. The checksums come from the same release, so they catch a corrupted or cut-off download, not a tampered release. If GitHub's API rate-limits your network, they fall back to the repository's latest-release redirect. Re-run to upgrade. `BRIDGEHOOK_VERSION=0.1.0` pins a version and `BRIDGEHOOK_INSTALL_DIR` changes where it goes (default `~/.local/bin`, or `%LOCALAPPDATA%\bridgehook\bin` added to your user PATH). From source: `cargo install --path cli`.
+
+## Use
+
```sh
bridgehook login # pair this machine (approve the code in the browser)
bridgehook forward 3000 # prints https://.bridgehook.dev, forwards to localhost:3000
@@ -30,6 +44,17 @@ Ctrl-C finishes the webhook in hand and stops; a second Ctrl-C quits at once.
The protocol lives in `crates/bridgehook-core`, a port of the extension's `QueueDrainer`: the queue is read oldest first, each webhook is claimed before it is forwarded (so the extension and the CLI never both deliver it), a server that is down holds the queue without using up retries, and a webhook that fails three times while the server is up is recorded and skipped. The live stream (`/api/me/stream`) only wakes the drain, so a live webhook never overtakes a queued one. Requests are signed with the channel's P-256 key.
+## Releasing
+
+```sh
+pnpm release:cli # cross-build the 5 binaries + SHA256SUMS into dist/cli/v
+pnpm test:install # both installers in clean glibc, musl and PowerShell containers
+scripts/publish-cli-release.sh # upload as a draft cli-v release
+scripts/publish-cli-release.sh --publish # or publish at once; the installers pick it up
+```
+
+The version comes from `[workspace.package]` in `Cargo.toml`. Builds need Docker (the cargo-zigbuild image carries the macOS SDK and the musl and Windows toolchains).
+
## Checks
```sh
diff --git a/crates/bridgehook-core/src/config.rs b/crates/bridgehook-core/src/config.rs
index c5361ae..60904ed 100644
--- a/crates/bridgehook-core/src/config.rs
+++ b/crates/bridgehook-core/src/config.rs
@@ -184,15 +184,31 @@ impl ChannelMap {
mod tests {
use super::*;
- fn temp_dir() -> PathBuf {
- let mut raw = [0u8; 8];
- rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut raw);
- std::env::temp_dir().join(format!("bh-config-test-{}", hex::encode(raw)))
+ /// A unique directory under the system temp dir, removed when dropped
+ /// so test runs do not pile up in /tmp.
+ struct TempDir(PathBuf);
+
+ impl TempDir {
+ fn new() -> Self {
+ let mut raw = [0u8; 8];
+ rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut raw);
+ Self(std::env::temp_dir().join(format!("bh-config-test-{}", hex::encode(raw))))
+ }
+ fn paths(&self) -> Paths {
+ Paths::at(&self.0)
+ }
+ }
+
+ impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
}
#[test]
fn token_round_trips_and_is_private() {
- let paths = Paths::at(temp_dir());
+ let dir = TempDir::new();
+ let paths = dir.paths();
assert_eq!(load_token(&paths).unwrap(), None);
let token = StoredToken {
token: "dvc_abc".into(),
@@ -222,7 +238,8 @@ mod tests {
#[test]
fn an_existing_config_dir_keeps_its_mode() {
use std::os::unix::fs::PermissionsExt;
- let dir = temp_dir();
+ let tmp = TempDir::new();
+ let dir = tmp.0.clone();
fs::create_dir_all(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
let paths = Paths::at(&dir);
@@ -242,7 +259,8 @@ mod tests {
#[test]
fn executor_id_is_stable() {
- let paths = Paths::at(temp_dir());
+ let dir = TempDir::new();
+ let paths = dir.paths();
let a = executor_id(&paths).unwrap();
let b = executor_id(&paths).unwrap();
assert_eq!(a, b);
@@ -251,7 +269,8 @@ mod tests {
#[test]
fn channel_map_is_per_relay() {
- let paths = Paths::at(temp_dir());
+ let dir = TempDir::new();
+ let paths = dir.paths();
let mut map = ChannelMap::load(&paths).unwrap();
map.set("https://a", "3000", "ch1");
map.set("https://b", "3000", "ch2");
diff --git a/docs/src/pages/Quickstart.tsx b/docs/src/pages/Quickstart.tsx
index 363a7b4..00074c4 100644
--- a/docs/src/pages/Quickstart.tsx
+++ b/docs/src/pages/Quickstart.tsx
@@ -40,6 +40,38 @@ export function Quickstart() {
Chrome is open.
+ From the terminal (CLI)
+
+ For servers, containers and anyone who lives in a terminal. One line installs the{" "}
+ bridgehook binary (macOS, Linux; x86_64 and arm64):
+
+
+ curl -fsSL https://bridgehook.dev/install.sh | sh
+
+ On Windows, in PowerShell:
+
+ irm https://bridgehook.dev/install.ps1 | iex
+
+
+ Both check that the download is intact against the release's SHA-256 checksums and
+ install nothing if it does not match. Re-run either line to upgrade; set{" "}
+ BRIDGEHOOK_VERSION to install a specific version. Then pair the machine and
+ forward a port:
+
+
+ {`bridgehook login # approve the code in your browser
+bridgehook forward 3000 # prints https://.bridgehook.dev`}
+
+
+ forward keeps the same URL for the same port across restarts, delivers webhooks
+ that arrived while it was stopped in order, and prints each one as it goes to your server.
+ Add --json for one JSON object per line, or --path /stripe to
+ accept only that path. Where you cannot open a browser (a container, CI), set{" "}
+ BRIDGEHOOK_TOKEN to a device token: the token field of{" "}
+ ~/.config/bridgehook/token.json on a machine you paired, which you can revoke
+ from the dashboard's device list.
+
+
With nothing installed
Open{" "}
@@ -73,7 +105,7 @@ export function Quickstart() {
- If neither the extension nor a dashboard tab is forwarding, webhooks are stored and
+ If nothing is forwarding (no extension, CLI or dashboard tab), webhooks are stored and
delivered in arrival order when one starts. How long history is kept depends on your plan;
see Billing.
diff --git a/package.json b/package.json
index 7c9ef52..69bce9a 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,8 @@
"test:cli": "cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings",
"e2e:cli": "node cli/e2e/cli.e2e.mjs",
"e2e:cli:local": "node cli/e2e/cli.e2e.mjs --local",
+ "release:cli": "scripts/release-cli.sh",
+ "test:install": "scripts/test-install.sh",
"deploy:relay": "pnpm --filter @bridgehook/relay deploy",
"deploy:web": "pnpm --filter @bridgehook/web deploy",
"typecheck": "pnpm -r typecheck",
diff --git a/scripts/lint-install-ps1.ps1 b/scripts/lint-install-ps1.ps1
new file mode 100644
index 0000000..b034374
--- /dev/null
+++ b/scripts/lint-install-ps1.ps1
@@ -0,0 +1,31 @@
+# Lint apps/web/public/install.ps1 with PSScriptAnalyzer: warnings and
+# errors, the PSGallery rule set, and syntax compatibility with Windows
+# PowerShell 5.1 and PowerShell 7. Exits 1 on any finding.
+#
+# docker run --rm -v "$PWD:/src:ro" mcr.microsoft.com/powershell:latest \
+# pwsh -NoProfile -File /src/scripts/lint-install-ps1.ps1
+param([string]$Path = (Join-Path $PSScriptRoot '../apps/web/public/install.ps1'))
+
+$ErrorActionPreference = 'Stop'
+# Pinned: reproducible, and 1.23+ refuses PowerShell older than 7.4.6.
+$analyzerVersion = '1.22.0'
+if (-not (Get-Module -ListAvailable PSScriptAnalyzer | Where-Object { $_.Version -eq $analyzerVersion })) {
+ Set-PSRepository PSGallery -InstallationPolicy Trusted
+ Install-Module PSScriptAnalyzer -RequiredVersion $analyzerVersion -Scope CurrentUser -Force
+}
+Import-Module PSScriptAnalyzer -RequiredVersion $analyzerVersion
+
+$findings = @()
+$findings += Invoke-ScriptAnalyzer -Path $Path -Severity Warning, Error
+$findings += Invoke-ScriptAnalyzer -Path $Path -Settings PSGallery -Severity Warning, Error
+$findings += Invoke-ScriptAnalyzer -Path $Path -IncludeRule PSUseCompatibleSyntax -Settings @{
+ Rules = @{ PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @('5.1', '7.0') } }
+}
+$findings = $findings | Sort-Object Line, RuleName -Unique
+
+if ($findings) {
+ $findings | Format-Table RuleName, Severity, Line, Message -AutoSize -Wrap | Out-String -Width 220 | Write-Host
+ Write-Host "$(@($findings).Count) finding(s) in $Path"
+ exit 1
+}
+Write-Host "PSScriptAnalyzer: no findings in $Path (warnings, errors, PSGallery, 5.1/7 syntax)"
diff --git a/scripts/publish-cli-release.sh b/scripts/publish-cli-release.sh
new file mode 100755
index 0000000..9d27565
--- /dev/null
+++ b/scripts/publish-cli-release.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Upload a built CLI release (scripts/release-cli.sh) to GitHub as cli-v.
+#
+# scripts/publish-cli-release.sh # draft: nothing public yet
+# scripts/publish-cli-release.sh --publish # public: the installers pick it up
+#
+# install.sh and install.ps1 install the newest stable (non-draft,
+# non-prerelease) cli-v* release, so a draft can be checked (download it,
+# run scripts/test-install.sh on it) before anyone can install it.
+# Publishing a draft later:
+# gh release edit cli-v --draft=false --latest
+# The release is marked "latest": when GitHub's API rate-limits an install,
+# the installers fall back to the repository's latest-release redirect.
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml")
+TAG="cli-v$VERSION"
+DIST="$ROOT/dist/cli/v$VERSION"
+draft=(--draft)
+[ "${1:-}" = --publish ] && draft=()
+
+[ -f "$DIST/SHA256SUMS" ] || { echo "no build in $DIST; run scripts/release-cli.sh" >&2; exit 1; }
+
+# The binaries must come from the commit the tag names, and that commit
+# must be on main, so anyone can rebuild and compare what they install.
+HEAD_COMMIT=$(git -C "$ROOT" rev-parse HEAD)
+[ -z "$(git -C "$ROOT" status --porcelain)" ] || { echo "uncommitted changes; publish from a clean checkout of main" >&2; exit 1; }
+git -C "$ROOT" fetch -q origin main
+git -C "$ROOT" merge-base --is-ancestor "$HEAD_COMMIT" origin/main ||
+ { echo "HEAD ($HEAD_COMMIT) is not on origin/main; merge first, then build and publish from main" >&2; exit 1; }
+built=$(cat "$DIST/COMMIT" 2>/dev/null || true)
+[ "$built" = "$HEAD_COMMIT" ] ||
+ { echo "$DIST was built from ${built:-an unknown commit}, not HEAD ($HEAD_COMMIT); run scripts/release-cli.sh again" >&2; exit 1; }
+(cd "$DIST" && sha256sum -c --quiet SHA256SUMS) || { echo "SHA256SUMS does not match $DIST" >&2; exit 1; }
+if gh release view "$TAG" >/dev/null 2>&1; then
+ echo "$TAG already exists; bump the workspace version in Cargo.toml for a new release" >&2
+ exit 1
+fi
+
+notes=$(mktemp)
+trap 'rm -f "$notes"' EXIT
+cat >"$notes" < dist/cli/v/
+#
+# Cross-compiles in the cargo-zigbuild container (Linux musl, macOS with its
+# SDK, Windows), so one Linux machine with Docker builds all five. Release
+# assets are bare binaries, which install.sh and install.ps1 download and
+# check against SHA256SUMS:
+#
+# bridgehook-x86_64-unknown-linux-musl
+# bridgehook-aarch64-unknown-linux-musl
+# bridgehook-x86_64-apple-darwin
+# bridgehook-aarch64-apple-darwin
+# bridgehook-x86_64-pc-windows-gnu.exe
+#
+# OFFLINE=1 builds from the crates already in ~/.cargo (no network in the
+# container). Publish with scripts/publish-cli-release.sh, which only takes
+# a build of a clean, pushed commit on main; the commit is recorded in
+# dist/cli/v/COMMIT. ALLOW_DIRTY=1 builds uncommitted changes for a
+# local try (such a build cannot be published).
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+IMAGE=${CARGO_ZIGBUILD_IMAGE:-ghcr.io/rust-cross/cargo-zigbuild:latest}
+TARGETS=(
+ x86_64-unknown-linux-musl
+ aarch64-unknown-linux-musl
+ x86_64-apple-darwin
+ aarch64-apple-darwin
+ x86_64-pc-windows-gnu
+)
+
+if [ -n "$(git -C "$ROOT" status --porcelain)" ] && [ "${ALLOW_DIRTY:-0}" != 1 ]; then
+ echo "the tree has uncommitted changes; commit them (or ALLOW_DIRTY=1 for a local try)" >&2
+ exit 1
+fi
+COMMIT=$(git -C "$ROOT" rev-parse HEAD)
+[ -z "$(git -C "$ROOT" status --porcelain)" ] || COMMIT="$COMMIT-dirty"
+
+VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml")
+[ -n "$VERSION" ] || { echo "no workspace version in Cargo.toml" >&2; exit 1; }
+OUT="$ROOT/dist/cli/v$VERSION"
+rm -rf "$OUT"
+mkdir -p "$OUT"
+
+CARGO_HOME_HOST=${CARGO_HOME:-$HOME/.cargo}
+mkdir -p "$CARGO_HOME_HOST"
+docker_args=(
+ --rm
+ -u "$(id -u):$(id -g)"
+ -v "$ROOT:/src"
+ -v "$CARGO_HOME_HOST:/cargo"
+ -e CARGO_HOME=/cargo
+ -e CARGO_TARGET_DIR=/src/target/cross
+ -e ZIG_GLOBAL_CACHE_DIR=/src/target/cross/zig-cache
+ -e HOME=/tmp
+ -w /src
+)
+offline=()
+if [ "${OFFLINE:-0}" = 1 ]; then
+ docker_args+=(--network none)
+ offline=(--offline)
+fi
+
+for target in "${TARGETS[@]}"; do
+ echo "==> $target"
+ docker run "${docker_args[@]}" "$IMAGE" \
+ cargo zigbuild --locked "${offline[@]}" --release -p bridgehook --target "$target"
+ src="$ROOT/target/cross/$target/release/bridgehook"
+ name="bridgehook-$target"
+ case $target in *windows*) src="$src.exe"; name="$name.exe" ;; esac
+ cp "$src" "$OUT/$name"
+done
+
+(cd "$OUT" && sha256sum bridgehook-* > SHA256SUMS)
+echo "$COMMIT" > "$OUT/COMMIT"
+echo
+echo "v$VERSION (commit $COMMIT):"
+for f in "$OUT"/bridgehook-*; do
+ printf '%10s %s\n' "$(wc -c <"$f")" "${f##*/}"
+done
+cat "$OUT/SHA256SUMS"
diff --git a/scripts/test-install.sh b/scripts/test-install.sh
new file mode 100755
index 0000000..94d7d0f
--- /dev/null
+++ b/scripts/test-install.sh
@@ -0,0 +1,228 @@
+#!/usr/bin/env bash
+# Run apps/web/public/install.sh in clean containers against a local mirror
+# of a release (scripts/release-cli.sh output), with no internet access.
+#
+# scripts/test-install.sh [dist/cli/v]
+#
+# The mirror also stands in for GitHub (BRIDGEHOOK_GITHUB_URL and
+# BRIDGEHOOK_GITHUB_API_URL): a releases API whose newest entry is a
+# prerelease, then a non-CLI release, then the stable CLI release (pretty
+# and minified JSON); a rate-limited API (403) with GitHub's latest-release
+# redirect to fall back on; and a redirect to a non-CLI release.
+#
+# Checks: install on glibc (curl) and musl (busybox wget); the installed
+# binary runs; re-running upgrades in place; a tampered binary or a missing
+# SHA256SUMS entry aborts with nothing installed; the PATH hint appears when
+# the install directory is not on PATH. install.ps1 runs the same way
+# (`Get-Content | Invoke-Expression`, as `irm | iex` does) in the PowerShell
+# container: it cannot run the .exe or write the Windows registry there, so
+# it checks the download, the checksum, the installed file's hash and the
+# atomic replace.
+# shellcheck disable=SC2016 # single-quoted test and container scripts are
+# expanded later on purpose (by expect's bash, or the container's shell)
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+if [ $# -ge 1 ]; then
+ DIST=$1
+else
+ DIST=$(find "$ROOT/dist/cli" -mindepth 1 -maxdepth 1 -type d -name 'v*' | sort -V | tail -n 1)
+fi
+[ -f "$DIST/SHA256SUMS" ] || { echo "no release in $DIST; run scripts/release-cli.sh" >&2; exit 1; }
+INSTALL_SH="$ROOT/apps/web/public/install.sh"
+NET=bh-install-test
+MIRROR=bh-install-mirror
+# Under target/, not /tmp: /tmp is often a small tmpfs with a quota.
+mkdir -p "$ROOT/target"
+MIRRORS=$(mktemp -d "$ROOT/target/install-test.XXXXXX")
+pass=0
+fail=0
+
+cleanup() {
+ docker rm -f "$MIRROR" >/dev/null 2>&1 || true
+ docker network rm "$NET" >/dev/null 2>&1 || true
+ rm -rf "$MIRRORS"
+}
+trap cleanup EXIT
+cleanup
+
+# expect