aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:27:42 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:45 +0300
commit0956ad8c5883014ebf88664099fae41edd3fdc9e (patch)
tree8fb3ad282872547a9be2e9790b157d1bc90ca8c2
parent745ce4254c646922d667ec13dd21963656405c12 (diff)
feat: add windows preview installer
-rw-r--r--.github/workflows/preview.yml28
-rw-r--r--docs/next/CHANGELOG.md2
-rw-r--r--docs/next/README.md8
-rw-r--r--docs/next/website/src/content/docs/index.mdx9
-rw-r--r--docs/next/website/src/content/docs/install.mdx12
-rw-r--r--docs/next/website/src/content/docs/windows-beta.mdx10
-rw-r--r--scripts/preview.py6
-rw-r--r--scripts/test_preview.py4
-rw-r--r--src/protocol/wire.rs67
-rw-r--r--src/update.rs121
-rw-r--r--website/install.ps1407
-rw-r--r--website/scripts/prepare-docs.mjs1
12 files changed, 643 insertions, 32 deletions
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 5963f1b0..a259ba54 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -129,6 +129,9 @@ jobs:
- target: aarch64-apple-darwin
os: macos-latest
name: herdr-macos-aarch64
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ name: herdr-windows-x86_64.exe
runs-on: ${{ matrix.os }}
env:
LIBGHOSTTY_VT_OPTIMIZE: ReleaseFast
@@ -178,12 +181,22 @@ jobs:
key: preview-${{ matrix.target }}
- name: Remove Zig caches
+ if: runner.os != 'Windows'
run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out
+ - name: Remove Zig caches
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ Remove-Item -Recurse -Force .zig-cache -ErrorAction SilentlyContinue
+ Remove-Item -Recurse -Force vendor/libghostty-vt/.zig-cache -ErrorAction SilentlyContinue
+ Remove-Item -Recurse -Force vendor/libghostty-vt/zig-out -ErrorAction SilentlyContinue
+
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package artifact
+ if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
@@ -212,6 +225,18 @@ jobs:
echo "channel=preview"
} >> BUILD_INFO.txt
+ - name: Package artifact
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ Copy-Item target\${{ matrix.target }}\release\herdr.exe ${{ matrix.name }}
+ $hash = (Get-FileHash -Algorithm SHA256 ${{ matrix.name }}).Hash.ToLowerInvariant()
+ "$hash ${{ matrix.name }}" | Out-File -Encoding ascii ${{ matrix.name }}.sha256
+ "commit=${{ needs.preflight.outputs.commit }}" | Out-File -Encoding utf8 BUILD_INFO.txt
+ "build_id=${{ needs.preflight.outputs.build_id }}" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
+ "target=${{ matrix.target }}" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
+ "channel=preview" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
+
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
@@ -253,7 +278,7 @@ jobs:
result = {}
for path in pathlib.Path('artifacts').glob('herdr-*/*.sha256'):
digest, name = path.read_text().split()[:2]
- target = name.removeprefix('herdr-')
+ target = name.removeprefix('herdr-').removesuffix('.exe')
result[target] = digest
pathlib.Path('preview-sha256.json').write_text(json.dumps(result, indent=2) + '\n')
PY
@@ -273,6 +298,7 @@ jobs:
artifacts/herdr-linux-aarch64/herdr-linux-aarch64
artifacts/herdr-macos-x86_64/herdr-macos-x86_64
artifacts/herdr-macos-aarch64/herdr-macos-aarch64
+ artifacts/herdr-windows-x86_64.exe/herdr-windows-x86_64.exe
- name: Update preview manifest
run: |
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 402e7caf..259b5bc5 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -13,7 +13,7 @@
- Added directional pane swap with `prefix+shift+h/j/k/l`, a pane context-menu swap action, pane layout/neighbor/edge/focus/resize socket APIs, matching CLI commands, and optional `pane split --ratio` support.
- Added `herdr pane zoom` and the `pane.zoom` socket API to toggle, set, or clear tab-local pane zoom from scripts and integrations.
- Added toast ergonomics controls for delayed agent notifications, in-app toast placement, copied-to-clipboard feedback, and the `notification.show` socket API with `herdr notification show` and optional `none`, `done`, or `request` sounds. (#486)
-- Added native Windows beta documentation and platform capability tracking for ConPTY panes, semantic client input, Windows agent discovery, known partial cwd behavior, and unsupported Unix-only features such as live handoff, direct terminal attach, and `herdr --remote` from the Windows binary.
+- Added native Windows beta documentation, `install.ps1`, preview Windows release assets, update-channel wiring, and platform capability tracking for ConPTY panes, semantic client input, Windows agent discovery, known partial cwd behavior, and unsupported Unix-only features such as live handoff, direct terminal attach, and `herdr --remote` from the Windows binary.
## [0.6.8] - 2026-06-04
diff --git a/docs/next/README.md b/docs/next/README.md
index 0f04759f..558164ce 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -25,6 +25,12 @@ workspaces, tabs, panes. mouse-native: click, drag, split. every agent at a glan
curl -fsSL https://herdr.dev/install.sh | sh
```
+on windows beta:
+
+```powershell
+$env:HERDR_CHANNEL="preview"; powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"
+```
+
or install with homebrew:
```bash
@@ -37,7 +43,7 @@ or install with mise:
mise use -g herdr
```
-or download the binary from [releases](https://github.com/ogulcancelik/herdr/releases). requires linux or macos.
+or download the binary from [releases](https://github.com/ogulcancelik/herdr/releases). native windows support is beta.
## quick start
diff --git a/docs/next/website/src/content/docs/index.mdx b/docs/next/website/src/content/docs/index.mdx
index f21ec902..27d41cb8 100644
--- a/docs/next/website/src/content/docs/index.mdx
+++ b/docs/next/website/src/content/docs/index.mdx
@@ -23,11 +23,18 @@ curl -fsSL https://herdr.dev/install.sh | sh
herdr
```
+On Windows beta:
+
+```powershell
+$env:HERDR_CHANNEL="preview"; powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"
+herdr
+```
+
## Start here
<CardGrid>
<Card title="Install">
- Install Herdr on Linux or macOS, update it later, and verify the binary.
+ Install Herdr on Linux, macOS, or Windows beta, update it later, and verify the binary.
[Install Herdr →](/docs/install/)
</Card>
diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx
index 41139821..e9ef8a53 100644
--- a/docs/next/website/src/content/docs/install.mdx
+++ b/docs/next/website/src/content/docs/install.mdx
@@ -7,13 +7,19 @@ Herdr ships as a single binary for Linux and macOS. Native Windows support is be
## Install
-Run the installer:
+On Linux or macOS, run:
```bash
curl -fsSL https://herdr.dev/install.sh | sh
```
-The installer downloads the right release binary for your platform and places it on your PATH.
+On Windows beta, install the preview channel:
+
+```powershell
+$env:HERDR_CHANNEL="preview"; powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"
+```
+
+The installer downloads the right release binary for your platform and places it on your PATH. The Windows installer uses versioned install folders and updates a `current` junction, so updates do not need to overwrite a running `herdr.exe`.
## Install with Homebrew
@@ -122,7 +128,7 @@ Choose the asset that matches your system:
| macOS Apple silicon | `herdr-macos-aarch64` |
| Windows x86_64 beta | `herdr-windows-x86_64.exe` |
-Make it executable and move it somewhere on your PATH.
+On Linux or macOS, make it executable and move it somewhere on your PATH.
```bash
chmod +x herdr-linux-x86_64
diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx
index 1d6e929f..f1663255 100644
--- a/docs/next/website/src/content/docs/windows-beta.mdx
+++ b/docs/next/website/src/content/docs/windows-beta.mdx
@@ -5,6 +5,16 @@ description: Native Windows support status, supported workflows, and known limit
Native Windows support is beta. It runs as the same Herdr version as Linux and macOS, but some Unix features are intentionally unavailable on Windows.
+Install native Windows beta builds with PowerShell:
+
+```powershell
+$env:HERDR_CHANNEL="preview"; powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"
+```
+
+Windows beta builds ship through the preview channel first. The Windows installer stores releases under `%USERPROFILE%\.herdr\packages\standalone\releases`, points `%LOCALAPPDATA%\Programs\Herdr\bin` at the current release, and keeps a small number of older releases so running processes do not block updates.
+
+For internal beta testing, `HERDR_MANIFEST_URL` can point the installer at a custom manifest instead of Herdr's stable or preview manifest.
+
## Supported in beta
| Capability | Status |
diff --git a/scripts/preview.py b/scripts/preview.py
index df8589bc..746a157a 100644
--- a/scripts/preview.py
+++ b/scripts/preview.py
@@ -13,8 +13,12 @@ ASSET_TARGETS = (
"linux-aarch64",
"macos-x86_64",
"macos-aarch64",
+ "windows-x86_64",
)
-EXPECTED_ASSET_NAMES = {target: f"herdr-{target}" for target in ASSET_TARGETS}
+EXPECTED_ASSET_NAMES = {
+ **{target: f"herdr-{target}" for target in ASSET_TARGETS},
+ "windows-x86_64": "herdr-windows-x86_64.exe",
+}
HIDDEN_SUBJECTS = (
"release:",
"docs: update website manifest",
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
index 1118058b..5cd5be93 100644
--- a/scripts/test_preview.py
+++ b/scripts/test_preview.py
@@ -47,6 +47,10 @@ class PreviewNotesTests(unittest.TestCase):
data["assets"]["linux-x86_64"]["sha256"],
"deadbeef",
)
+ self.assertEqual(
+ data["assets"]["windows-x86_64"]["url"],
+ "https://github.com/ogulcancelik/herdr/releases/download/preview-2026-06-02-abcdef123456/herdr-windows-x86_64.exe",
+ )
self.assertIn("2026-06-02-abcdef123456", data["builds"])
def test_hidden_subjects_include_preview_manifest_commits(self):
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 04d1b1ae..357f414e 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -332,9 +332,6 @@ pub enum ClientMessage {
data: Vec<u8>,
},
- /// Structured input events from platform clients that do not expose Unix-style raw bytes.
- InputEvents { events: Vec<ClientInputEvent> },
-
/// Image bytes read from the client's local clipboard for remote paste bridging.
ClipboardImage {
/// Image file extension without a leading dot.
@@ -381,6 +378,9 @@ pub enum ClientMessage {
/// Crossterm-compatible modifier bits for forwarded mouse wheel events.
modifiers: u8,
},
+
+ /// Structured input events from platform clients that do not expose Unix-style raw bytes.
+ InputEvents { events: Vec<ClientInputEvent> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -929,6 +929,67 @@ mod tests {
}
#[test]
+ fn client_message_wire_tags_preserve_protocol_13_order() {
+ fn tag(msg: &ClientMessage) -> u8 {
+ *bincode::serde::encode_to_vec(msg, bincode::config::standard())
+ .unwrap()
+ .first()
+ .expect("encoded client message should include enum tag")
+ }
+
+ assert_eq!(
+ tag(&ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
+ launch_mode: ClientLaunchMode::App,
+ }),
+ 0
+ );
+ assert_eq!(tag(&ClientMessage::Input { data: Vec::new() }), 1);
+ assert_eq!(
+ tag(&ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: Vec::new(),
+ }),
+ 2
+ );
+ assert_eq!(
+ tag(&ClientMessage::Resize {
+ cols: 80,
+ rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ }),
+ 3
+ );
+ assert_eq!(tag(&ClientMessage::Detach), 4);
+ assert_eq!(
+ tag(&ClientMessage::AttachTerminal {
+ terminal_id: "term".to_owned(),
+ takeover: false,
+ }),
+ 5
+ );
+ assert_eq!(
+ tag(&ClientMessage::AttachScroll {
+ source: AttachScrollSource::Wheel,
+ direction: AttachScrollDirection::Up,
+ lines: 1,
+ column: None,
+ row: None,
+ modifiers: 0,
+ }),
+ 6
+ );
+ assert_eq!(tag(&ClientMessage::InputEvents { events: Vec::new() }), 7);
+ }
+
+ #[test]
fn client_input_events_roundtrip() {
let msg = ClientMessage::InputEvents {
events: vec![
diff --git a/src/update.rs b/src/update.rs
index 4f192e3a..6b428b78 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -609,6 +609,42 @@ fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String>
Ok(())
}
+#[cfg(windows)]
+fn install_windows_update_with_installer(channel: UpdateChannel) -> Result<(), String> {
+ let status = Command::new("powershell")
+ .args([
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ "irm https://herdr.dev/install.ps1 | iex",
+ ])
+ .env("HERDR_CHANNEL", channel.as_str())
+ .status()
+ .map_err(|err| format!("failed to run Windows installer: {err}"))?;
+
+ if !status.success() {
+ return Err(format!("Windows installer failed with status {status}"));
+ }
+
+ Ok(())
+}
+
+#[cfg(windows)]
+fn windows_installed_herdr_exe_path() -> Result<PathBuf, String> {
+ if let Some(install_dir) = env::var_os("HERDR_INSTALL_DIR").filter(|value| !value.is_empty()) {
+ return Ok(PathBuf::from(install_dir).join("herdr.exe"));
+ }
+
+ let local_app_data = env::var_os("LOCALAPPDATA")
+ .ok_or("LOCALAPPDATA is not set; cannot locate Herdr install")?;
+ Ok(PathBuf::from(local_app_data)
+ .join("Programs")
+ .join("Herdr")
+ .join("bin")
+ .join("herdr.exe"))
+}
+
// ---------------------------------------------------------------------------
// Upgrade flow helpers
// ---------------------------------------------------------------------------
@@ -1901,32 +1937,73 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
if let Some(commit) = &release.commit {
tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build");
}
- eprintln!("downloading {}...", release.label());
if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
tracing::warn!("failed to save pending release notes: {e}");
}
- let downloaded_update = download_update(&release)?;
- let updated_exe = downloaded_update.current_exe.clone();
- eprintln!("downloaded {}", release.label());
- if !options.live_handoff
- && !prompt_to_complete_plain_update(&server_update_decisions, &release)?
+
+ #[cfg(windows)]
{
- eprintln!("Herdr was not updated.");
- eprintln!("Stop running Herdr sessions when ready, then run `herdr update` again.");
- return Ok(current);
- }
- install_downloaded_update(downloaded_update)?;
- eprintln!("installed {}", release.label());
- let server_update_decisions = if options.live_handoff {
- server_update_decisions
- } else {
- mark_plain_update_stop_decisions(server_update_decisions)
- };
- let server_update_outcomes =
- apply_running_session_update_decisions(&release, &updated_exe, server_update_decisions)?;
- print_outdated_integration_notice_with_updated_binary(&updated_exe);
+ if !options.live_handoff
+ && !prompt_to_complete_plain_update(&server_update_decisions, &release)?
+ {
+ eprintln!("Herdr was not updated.");
+ eprintln!("Stop running Herdr sessions when ready, then run `herdr update` again.");
+ return Ok(current);
+ }
- print_running_session_update_outcomes(&server_update_outcomes, &release);
+ eprintln!(
+ "installing {} with the Windows installer...",
+ release.label()
+ );
+ install_windows_update_with_installer(channel)?;
+ let updated_exe = windows_installed_herdr_exe_path()?;
+ eprintln!("installed {}", release.label());
+ let server_update_decisions = if options.live_handoff {
+ server_update_decisions
+ } else {
+ mark_plain_update_stop_decisions(server_update_decisions)
+ };
+ let server_update_outcomes = apply_running_session_update_decisions(
+ &release,
+ &updated_exe,
+ server_update_decisions,
+ )?;
+ print_outdated_integration_notice_with_updated_binary(&updated_exe);
+
+ print_running_session_update_outcomes(&server_update_outcomes, &release);
+
+ return Ok(release.version);
+ }
+
+ #[cfg(not(windows))]
+ {
+ eprintln!("downloading {}...", release.label());
+ let downloaded_update = download_update(&release)?;
+ let updated_exe = downloaded_update.current_exe.clone();
+ eprintln!("downloaded {}", release.label());
+ if !options.live_handoff
+ && !prompt_to_complete_plain_update(&server_update_decisions, &release)?
+ {
+ eprintln!("Herdr was not updated.");
+ eprintln!("Stop running Herdr sessions when ready, then run `herdr update` again.");
+ return Ok(current);
+ }
+ install_downloaded_update(downloaded_update)?;
+ eprintln!("installed {}", release.label());
+ let server_update_decisions = if options.live_handoff {
+ server_update_decisions
+ } else {
+ mark_plain_update_stop_decisions(server_update_decisions)
+ };
+ let server_update_outcomes = apply_running_session_update_decisions(
+ &release,
+ &updated_exe,
+ server_update_decisions,
+ )?;
+ print_outdated_integration_notice_with_updated_binary(&updated_exe);
+
+ print_running_session_update_outcomes(&server_update_outcomes, &release);
+ }
Ok(release.version)
}
@@ -2077,6 +2154,8 @@ fn platform_target() -> (&'static str, &'static str) {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
+ } else if cfg!(target_os = "windows") {
+ "windows"
} else {
"unknown"
};
diff --git a/website/install.ps1 b/website/install.ps1
new file mode 100644
index 00000000..d07a5f1c
--- /dev/null
+++ b/website/install.ps1
@@ -0,0 +1,407 @@
+[CmdletBinding()]
+param(
+ [ValidateSet("stable", "preview")]
+ [string]$Channel = $env:HERDR_CHANNEL,
+ [string]$ManifestUrl = $env:HERDR_MANIFEST_URL,
+ [string]$InstallDir = $env:HERDR_INSTALL_DIR,
+ [int]$Retain = 3
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+
+if ([string]::IsNullOrWhiteSpace($Channel)) {
+ $Channel = "stable"
+}
+
+function Write-Step {
+ param([string]$Message)
+ Write-Host "==> $Message"
+}
+
+function Write-WarningStep {
+ param([string]$Message)
+ Write-Warning $Message
+}
+
+function Get-HerdrCommandSource {
+ $existing = Get-Command herdr -ErrorAction SilentlyContinue
+ if ($null -eq $existing) {
+ return $null
+ }
+
+ return $existing.Source
+}
+
+function Test-PathStartsWith {
+ param(
+ [string]$Path,
+ [string]$Prefix
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Prefix)) {
+ return $false
+ }
+
+ try {
+ $normalizedPath = [System.IO.Path]::GetFullPath($Path)
+ $normalizedPrefix = [System.IO.Path]::GetFullPath($Prefix).TrimEnd("\") + "\"
+ return $normalizedPath.StartsWith($normalizedPrefix, [System.StringComparison]::OrdinalIgnoreCase)
+ } catch {
+ return $false
+ }
+}
+
+function Path-Contains {
+ param(
+ [string]$PathValue,
+ [string]$Entry
+ )
+
+ if ([string]::IsNullOrWhiteSpace($PathValue)) {
+ return $false
+ }
+
+ $needle = $Entry.TrimEnd("\")
+ foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) {
+ if ($segment.TrimEnd("\") -ieq $needle) {
+ return $true
+ }
+ }
+
+ return $false
+}
+
+function Prepend-PathEntry {
+ param(
+ [string]$PathValue,
+ [string]$Entry
+ )
+
+ $needle = $Entry.TrimEnd("\")
+ $segments = @($Entry)
+ if (-not [string]::IsNullOrWhiteSpace($PathValue)) {
+ $segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) |
+ Where-Object { $_.TrimEnd("\") -ine $needle }
+ }
+
+ return ($segments -join ";")
+}
+
+function Get-ManifestAsset {
+ param(
+ [object]$Manifest,
+ [string]$Target
+ )
+
+ $property = $Manifest.assets.PSObject.Properties[$Target]
+ if ($null -eq $property) {
+ throw "Release manifest does not include a binary for $Target."
+ }
+
+ $asset = $property.Value
+ if ($asset -is [string]) {
+ return [PSCustomObject]@{
+ Url = $asset
+ Sha256 = $null
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$asset.url)) {
+ throw "Release manifest asset $Target is missing a URL."
+ }
+
+ return [PSCustomObject]@{
+ Url = [string]$asset.url
+ Sha256 = if ([string]::IsNullOrWhiteSpace([string]$asset.sha256)) { $null } else { [string]$asset.sha256 }
+ }
+}
+
+function ConvertTo-ManifestObject {
+ param([object]$Manifest)
+
+ if ($Manifest -isnot [string]) {
+ return $Manifest
+ }
+
+ $json = $Manifest.TrimStart([char]0xFEFF)
+ if ($json.StartsWith("")) {
+ $json = $json.Substring(3)
+ }
+
+ return $json | ConvertFrom-Json
+}
+
+function Test-FileDigest {
+ param(
+ [string]$Path,
+ [string]$ExpectedDigest
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ExpectedDigest)) {
+ return
+ }
+
+ $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actual -ne $ExpectedDigest.ToLowerInvariant()) {
+ throw "Downloaded Herdr checksum did not match. Expected $ExpectedDigest but got $actual."
+ }
+}
+
+function Invoke-WithInstallLock {
+ param(
+ [string]$LockPath,
+ [scriptblock]$Script
+ )
+
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LockPath) | Out-Null
+ $lock = $null
+ while ($null -eq $lock) {
+ try {
+ $lock = [System.IO.File]::Open(
+ $LockPath,
+ [System.IO.FileMode]::OpenOrCreate,
+ [System.IO.FileAccess]::ReadWrite,
+ [System.IO.FileShare]::None
+ )
+ } catch [System.IO.IOException] {
+ Start-Sleep -Milliseconds 250
+ }
+ }
+
+ try {
+ & $Script
+ } finally {
+ $lock.Dispose()
+ }
+}
+
+function Test-IsJunction {
+ param([string]$Path)
+
+ if (-not (Test-Path -LiteralPath $Path)) {
+ return $false
+ }
+
+ $item = Get-Item -LiteralPath $Path -Force
+ return ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $item.LinkType -eq "Junction"
+}
+
+function Set-ManagedJunction {
+ param(
+ [string]$LinkPath,
+ [string]$TargetPath,
+ [string]$ManagedTargetPrefix
+ )
+
+ if (Test-Path -LiteralPath $LinkPath) {
+ $item = Get-Item -LiteralPath $LinkPath -Force
+ if (Test-IsJunction -Path $LinkPath) {
+ $existingTarget = [string]$item.Target
+ if (-not [string]::IsNullOrWhiteSpace($ManagedTargetPrefix)) {
+ $ownedPrefix = $ManagedTargetPrefix.TrimEnd("\")
+ if (-not $existingTarget.StartsWith($ownedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to retarget junction at $LinkPath because it is not managed by this installer."
+ }
+ }
+ if ($existingTarget.Equals($TargetPath, [System.StringComparison]::OrdinalIgnoreCase)) {
+ return
+ }
+ Remove-Item -LiteralPath $LinkPath -Force
+ } elseif ($item.PSIsContainer) {
+ if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) {
+ throw "Refusing to replace non-empty directory at $LinkPath with a junction."
+ }
+ Remove-Item -LiteralPath $LinkPath -Force
+ } else {
+ throw "Refusing to replace file at $LinkPath with a junction."
+ }
+ }
+
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LinkPath) | Out-Null
+ New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null
+}
+
+function Remove-StaleInstallArtifacts {
+ param([string]$ReleasesDir)
+
+ if (-not (Test-Path -LiteralPath $ReleasesDir -PathType Container)) {
+ return
+ }
+
+ Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue |
+ Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+function Remove-OldReleases {
+ param(
+ [string]$ReleasesDir,
+ [string]$CurrentReleaseDir,
+ [int]$Keep
+ )
+
+ if ($Keep -lt 1 -or -not (Test-Path -LiteralPath $ReleasesDir -PathType Container)) {
+ return
+ }
+
+ $currentFullPath = [System.IO.Path]::GetFullPath($CurrentReleaseDir)
+ $releaseDirs = Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -ErrorAction SilentlyContinue |
+ Where-Object { -not $_.Name.StartsWith(".staging.") } |
+ Sort-Object LastWriteTimeUtc -Descending
+ $kept = 0
+ foreach ($dir in $releaseDirs) {
+ $dirFullPath = [System.IO.Path]::GetFullPath($dir.FullName)
+ if ($dirFullPath.Equals($currentFullPath, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $kept += 1
+ continue
+ }
+ if ($kept -lt $Keep) {
+ $kept += 1
+ continue
+ }
+ Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
+
+function Resolve-HerdrVersion {
+ param(
+ [object]$Manifest,
+ [string]$SelectedChannel
+ )
+
+ if ($SelectedChannel -eq "preview") {
+ if ([string]::IsNullOrWhiteSpace([string]$Manifest.base_version) -or [string]::IsNullOrWhiteSpace([string]$Manifest.build_id)) {
+ throw "Preview manifest is missing base_version or build_id."
+ }
+ return "$($Manifest.base_version)-preview.$($Manifest.build_id)"
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$Manifest.version)) {
+ throw "Stable manifest is missing version."
+ }
+ return [string]$Manifest.version
+}
+
+if ($env:OS -ne "Windows_NT") {
+ Write-Error "install.ps1 supports Windows only. Use install.sh on Linux or macOS."
+ exit 1
+}
+
+if (-not [Environment]::Is64BitOperatingSystem) {
+ Write-Error "Herdr requires 64-bit Windows."
+ exit 1
+}
+
+$architecture = [System.Runtime.InteropServices.RuntimeInformation,mscorlib]::OSArchitecture.ToString()
+switch ($architecture) {
+ "X64" {
+ $target = "windows-x86_64"
+ $targetTriple = "x86_64-pc-windows-msvc"
+ }
+ default {
+ Write-Error "Unsupported Windows architecture: $architecture"
+ exit 1
+ }
+}
+
+if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
+ $ManifestUrl = if ($Channel -eq "preview") {
+ "https://herdr.dev/preview.json"
+ } else {
+ "https://herdr.dev/latest.json"
+ }
+}
+
+$herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {
+ Join-Path $env:USERPROFILE ".herdr"
+} else {
+ $env:HERDR_HOME
+}
+$standaloneRoot = Join-Path $herdrHome "packages\standalone"
+$releasesDir = Join-Path $standaloneRoot "releases"
+$currentDir = Join-Path $standaloneRoot "current"
+$lockPath = Join-Path $standaloneRoot "install.lock"
+
+$defaultVisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\Herdr\bin"
+$visibleBinDir = if ([string]::IsNullOrWhiteSpace($InstallDir)) {
+ $defaultVisibleBinDir
+} else {
+ $InstallDir
+}
+
+$existingHerdr = Get-HerdrCommandSource
+if (-not [string]::IsNullOrWhiteSpace($existingHerdr) -and -not (Test-PathStartsWith -Path $existingHerdr -Prefix $visibleBinDir)) {
+ Write-Step "Detected existing Herdr command at $existingHerdr"
+ Write-WarningStep "PATH order decides which Herdr runs. This installer will put $visibleBinDir first for future and current PowerShell sessions."
+}
+
+Write-Step "Fetching Herdr $Channel manifest"
+$manifest = ConvertTo-ManifestObject -Manifest (Invoke-RestMethod -Uri $ManifestUrl)
+$versionIdentity = Resolve-HerdrVersion -Manifest $manifest -SelectedChannel $Channel
+$asset = Get-ManifestAsset -Manifest $manifest -Target $target
+$safeVersionIdentity = $versionIdentity -replace '[^0-9A-Za-z._-]', '-'
+$releaseName = "$safeVersionIdentity-$targetTriple"
+$releaseDir = Join-Path $releasesDir $releaseName
+
+Write-Step "Installing Herdr $versionIdentity for $targetTriple"
+$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-install-" + [System.Guid]::NewGuid().ToString("N"))
+New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
+
+try {
+ Invoke-WithInstallLock -LockPath $lockPath -Script {
+ Remove-StaleInstallArtifacts -ReleasesDir $releasesDir
+
+ if (-not (Test-Path -LiteralPath (Join-Path $releaseDir "herdr.exe") -PathType Leaf)) {
+ if (Test-Path -LiteralPath $releaseDir) {
+ Remove-Item -LiteralPath $releaseDir -Recurse -Force
+ }
+
+ $downloadPath = Join-Path $tempDir "herdr.exe"
+ $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID"
+ Write-Step "Downloading Herdr"
+ Invoke-WebRequest -Uri $asset.Url -OutFile $downloadPath
+ Test-FileDigest -Path $downloadPath -ExpectedDigest $asset.Sha256
+
+ New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null
+ Copy-Item -LiteralPath $downloadPath -Destination (Join-Path $stagingDir "herdr.exe")
+ Move-Item -LiteralPath $stagingDir -Destination $releaseDir
+ }
+
+ Set-ManagedJunction -LinkPath $currentDir -TargetPath $releaseDir -ManagedTargetPrefix $releasesDir
+ Set-ManagedJunction -LinkPath $visibleBinDir -TargetPath $releaseDir -ManagedTargetPrefix $standaloneRoot
+
+ $herdrCommand = Join-Path $visibleBinDir "herdr.exe"
+ & $herdrCommand --version *> $null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Installed Herdr command failed verification: $herdrCommand --version"
+ }
+
+ Remove-OldReleases -ReleasesDir $releasesDir -CurrentReleaseDir $releaseDir -Keep $Retain
+ }
+} finally {
+ Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+$newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir
+if ($newUserPath -cne $userPath) {
+ [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
+ Write-Step "PATH updated for future PowerShell sessions."
+} else {
+ Write-Step "$visibleBinDir is already first on PATH."
+}
+
+$newProcessPath = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir
+if ($newProcessPath -cne $env:Path) {
+ $env:Path = $newProcessPath
+}
+
+$resolvedHerdr = Get-HerdrCommandSource
+if (-not (Test-PathStartsWith -Path $resolvedHerdr -Prefix $visibleBinDir)) {
+ Write-WarningStep "PowerShell still resolves herdr to $resolvedHerdr. Open a new PowerShell window or inspect PATH order manually."
+}
+
+Write-Step "Current PowerShell session: herdr"
+Write-Step "Future PowerShell windows: open a new PowerShell window and run: herdr"
+Write-Host "Herdr $versionIdentity installed successfully."
diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs
index 19201a18..f7892d37 100644
--- a/website/scripts/prepare-docs.mjs
+++ b/website/scripts/prepare-docs.mjs
@@ -25,6 +25,7 @@ async function preparePublicAssets() {
for (const file of [
'install.sh',
+ 'install.ps1',
'latest.json',
'preview.json',
'robots.txt',