# Canonical ngit standalone bootstrap installer for Windows.
#
# The website deployment renders the two placeholders below from one exact,
# validated NIP-82 main-channel release. This script intentionally does not
# discover "latest" or execute unpinned downloads at runtime.
param(
[ValidateSet("auto", "cargo", "standalone")][string]$Method = "auto",
[string]$InstallDirectory = "",
[switch]$Repair,
[switch]$AllowDowngrade
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$version = "@@VERSION@@"
$assetManifest = @'
@@ASSET_MANIFEST@@
'@
$receiptFilename = ".ngit-install-receipt.json"
$binaryNames = @("ngit.exe", "git-remote-nostr.exe")
function Write-Info([string]$Message) {
Write-Host "[INFO] $Message"
}
function Fail-Install([string]$Message) {
throw $Message
}
function Select-ReleaseAsset {
$matches = @(
foreach ($line in ($assetManifest -split "`r?`n")) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
$fields = $line.Split("|")
if ($fields.Count -eq 5 -and $fields[0] -eq "windows-x86_64") {
,$fields
}
}
)
if ($matches.Count -ne 1) {
Fail-Install "the release has no unique asset for windows-x86_64"
}
return $matches[0]
}
function Add-UserPath([string]$InstallDirectory) {
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$entries = @($userPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$remaining = @($entries | Where-Object { $_.TrimEnd("\") -ine $InstallDirectory.TrimEnd("\") })
$updated = (@($InstallDirectory) + $remaining) -join ";"
if ($updated -ne $userPath) {
[Environment]::SetEnvironmentVariable("Path", $updated, "User")
Write-Info "Added $InstallDirectory to the user PATH; restart the terminal to use it"
}
}
function Write-Receipt([string]$InstallDirectory) {
$receipt = [ordered]@{
schema = 1
method = "standalone"
version = $version
} | ConvertTo-Json
$temporary = Join-Path $InstallDirectory "$receiptFilename.tmp.$PID"
[IO.File]::WriteAllText($temporary, "$receipt`n", [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $temporary -Destination (Join-Path $InstallDirectory $receiptFilename) -Force
}
function Get-CanonicalPath([string]$Path, [int]$Depth = 0) {
if ($Depth -gt 40) { Fail-Install "too many symlinks resolving $Path" }
$item = Get-Item -LiteralPath $Path -Force
$target = $item.PSObject.Properties["Target"]
if ($null -ne $target -and $target.Value) {
$link = @($target.Value)[0]
if (-not [IO.Path]::IsPathRooted($link)) { $link = Join-Path (Split-Path -Parent $item.FullName) $link }
return Get-CanonicalPath $link ($Depth + 1)
}
$parent = Split-Path -Parent $item.FullName
if ($parent -and $parent -ne $item.FullName) {
return Join-Path (Get-CanonicalPath $parent ($Depth + 1)) $item.Name
}
return $item.FullName
}
function Test-Receipt([string]$Directory) {
try {
$receipt = Get-Content -LiteralPath (Join-Path $Directory $receiptFilename) -Raw | ConvertFrom-Json
return $receipt.schema -eq 1 -and $receipt.method -eq "standalone" -and
$receipt.version -match '^\d+\.\d+\.\d+([-+][0-9A-Za-z.+-]+)?$'
} catch { return $false }
}
function Get-BinaryVersion([string]$Executable) {
$reported = & $Executable --version
if ($LASTEXITCODE -ne 0) { Fail-Install "version check failed: $Executable" }
return $reported
}
function Assert-Version([string]$Executable) {
if ($AllowDowngrade -or -not (Test-Path -LiteralPath $Executable)) { return }
$reported = Get-BinaryVersion $Executable
if ($reported -notmatch '^ngit (\d+\.\d+\.\d+)([-+][0-9A-Za-z.+-]+)?$') {
Fail-Install "cannot recognize the installed version; use -AllowDowngrade to authorize replacement"
}
if ([version]$Matches[1] -gt [version]$version) {
Fail-Install "installed $reported is newer than v$version; use -AllowDowngrade to authorize replacement"
}
}
function Assert-Destination([string]$Directory) {
$receiptPath = Join-Path $Directory $receiptFilename
if (Test-Path -LiteralPath $receiptPath) {
if (-not (Test-Receipt $Directory) -and -not $Repair) {
Fail-Install "invalid installer receipt; rerun with -Repair to repair this standalone installation"
}
} else {
foreach ($binary in $binaryNames) {
if (Test-Path -LiteralPath (Join-Path $Directory $binary)) {
Fail-Install "refusing to overwrite $binary without an installer receipt; choose a separate -InstallDirectory"
}
}
}
foreach ($name in (@($binaryNames) + $receiptFilename)) {
$path = Join-Path $Directory $name
if (Test-Path -LiteralPath $path) {
$item = Get-Item -LiteralPath $path -Force
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
Fail-Install "destination must be a regular file: $path"
}
}
}
}
function Show-PathGuidance([string]$Directory) {
foreach ($binary in $binaryNames) {
$active = Get-Command $binary -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
$expected = Join-Path $Directory $binary
if (-not $active -or (Get-CanonicalPath $active.Source) -ine $expected) {
$location = if ($active) { $active.Source } else { "not found" }
Write-Warning "$binary on PATH: $location; installed copy: $expected. Put $Directory first on PATH and restart your terminal. An earlier system PATH entry may also need to be removed."
}
}
}
function Install-Binaries([string]$Directory, [hashtable]$Sources) {
$lock = Join-Path $Directory ".ngit-install-lock"
try { New-Item -ItemType Directory -Path $lock -ErrorAction Stop | Out-Null }
catch { Fail-Install "installation is not writable, another installer is running, or $lock remains from an interrupted installation; inspect it before retrying" }
$stage = Join-Path $Directory ".ngit-install-$([Guid]::NewGuid())"
$touched = @()
$preserve = $false
try {
Assert-Destination $Directory
Assert-Version (Join-Path $Directory "ngit.exe")
New-Item -ItemType Directory -Path (Join-Path $stage "new") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $stage "old") | Out-Null
foreach ($binary in $binaryNames) {
Copy-Item -LiteralPath $Sources[$binary] -Destination (Join-Path $stage "new/$binary")
}
if ((Get-BinaryVersion (Join-Path $stage "new/ngit.exe")) -ne "ngit $version" -or
(Get-BinaryVersion (Join-Path $stage "new/git-remote-nostr.exe")) -ne "v$version") { Fail-Install "staged release failed its version checks" }
Write-Receipt (Join-Path $stage "new")
foreach ($binary in (@($binaryNames) + $receiptFilename)) {
$path = Join-Path $Directory $binary
if (Test-Path -LiteralPath $path) {
Copy-Item -LiteralPath $path -Destination (Join-Path $stage "old/$binary")
}
}
foreach ($binary in (@($binaryNames) + $receiptFilename)) {
$touched += $binary
Move-Item -LiteralPath (Join-Path $stage "new/$binary") -Destination (Join-Path $Directory $binary) -Force
}
$installedNgit = Get-BinaryVersion (Join-Path $Directory "ngit.exe")
if ($installedNgit -ne "ngit $version") { Fail-Install "installed ngit failed its version check" }
$installedRemote = Get-BinaryVersion (Join-Path $Directory "git-remote-nostr.exe")
if ($installedRemote -ne "v$version") { Fail-Install "installed helper failed its version check" }
} catch {
$failure = $_
foreach ($binary in $touched) {
try {
$backup = Join-Path $stage "old/$binary"
$path = Join-Path $Directory $binary
if (Test-Path -LiteralPath $backup) { Copy-Item -LiteralPath $backup -Destination $path -Force }
elseif (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force }
} catch { $preserve = $true; Write-Warning "Could not restore ${binary}: $_" }
}
if ($preserve) { Write-Warning "Rollback incomplete; preserve $stage/old and restore it before retrying." }
throw $failure
} finally {
if (-not $preserve -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force }
Remove-Item -LiteralPath $lock -Force
}
}
function Invoke-NgitInstall {
if ($version -notmatch '^\d+\.\d+\.\d+$') {
Fail-Install "installer template was not rendered with a stable release"
}
$existing = Get-Command ngit -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
$existingDirectory = ""
$cargoRoot = ""
if ($existing) {
$existingPath = Get-CanonicalPath $existing.Source
$existingDirectory = Split-Path -Parent $existingPath
$root = Split-Path -Parent $existingDirectory
$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $HOME ".cargo" }
if ((Split-Path -Leaf $existingDirectory) -eq "bin" -and
((Test-Path -LiteralPath (Join-Path $root ".crates.toml")) -or
(Test-Path -LiteralPath (Join-Path $root ".crates2.json")) -or $root -eq $cargoHome)) { $cargoRoot = $root }
Assert-Version $existingPath
}
if ($Method -eq "auto" -and $cargoRoot -and -not (Test-Path -LiteralPath (Join-Path $existingDirectory $receiptFilename))) {
$Method = "cargo"
}
if ($Method -eq "cargo") {
if (-not $cargoRoot) { Fail-Install "the active ngit is not a recognized Cargo installation" }
if ($InstallDirectory -or $Repair) { Fail-Install "-InstallDirectory and -Repair apply only to standalone installation" }
Write-Info "Updating ngit to v$version through Cargo in $cargoRoot (default registry and default features)"
& cargo install ngit --locked --version $version --root $cargoRoot
if ($LASTEXITCODE -ne 0) { Fail-Install "Cargo installation failed" }
Show-PathGuidance $existingDirectory
return
}
if ($existing -and $Method -eq "auto" -and -not (Test-Receipt $existingDirectory) -and -not $Repair) {
if (Test-Path -LiteralPath (Join-Path $existingDirectory $receiptFilename)) {
Fail-Install "invalid standalone receipt; rerun with -Repair to repair it"
}
Fail-Install "Existing installation: $existingPath. For Cargo, rerun with -Method cargo. To install a separate standalone copy, use -Method standalone."
}
if (-not $InstallDirectory) {
if ($existingDirectory -and ((Test-Receipt $existingDirectory) -or ($Repair -and (Test-Path -LiteralPath (Join-Path $existingDirectory $receiptFilename))))) {
$InstallDirectory = $existingDirectory
} else {
$InstallDirectory = Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) "Programs/ngit/bin"
}
}
if (-not [IO.Path]::IsPathRooted($InstallDirectory)) { Fail-Install "-InstallDirectory must be an absolute path" }
New-Item -ItemType Directory -Path $InstallDirectory -Force | Out-Null
$InstallDirectory = Get-CanonicalPath $InstallDirectory
Assert-Destination $InstallDirectory
Assert-Version (Join-Path $InstallDirectory "ngit.exe")
$asset = Select-ReleaseAsset
$url = $asset[1]
$expectedSha = $asset[2]
$filename = $asset[3]
$mime = $asset[4]
$uri = $null
if (-not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https") {
Fail-Install "the rendered release asset URL is not HTTPS"
}
if ($expectedSha -notmatch '^[0-9a-f]{64}$') {
Fail-Install "the rendered asset SHA-256 is invalid"
}
if ([IO.Path]::GetFileName($filename) -ne $filename -or -not $filename.EndsWith(".zip")) {
Fail-Install "the rendered release asset filename is invalid"
}
if ($mime -ne "application/zip") {
Fail-Install "the rendered Windows release asset is not a ZIP archive"
}
$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) "ngit-install-$([Guid]::NewGuid())"
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
try {
$archive = Join-Path $temporaryDirectory $filename
Write-Info "Downloading ngit v$version for windows-x86_64"
Invoke-WebRequest -Uri $url -OutFile $archive
$actualSha = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha -ne $expectedSha) {
Fail-Install "SHA-256 mismatch: expected $expectedSha, observed $actualSha"
}
$extracted = Join-Path $temporaryDirectory "extracted"
Expand-Archive -LiteralPath $archive -DestinationPath $extracted
$sources = @{}
foreach ($binary in $binaryNames) {
$found = @(Get-ChildItem -LiteralPath $extracted -Recurse -File -Filter $binary)
if ($found.Count -ne 1) {
Fail-Install "release archive does not contain exactly one $binary"
}
$sources[$binary] = $found[0].FullName
}
$ngitReported = Get-BinaryVersion $sources["ngit.exe"]
if ($ngitReported -ne "ngit $version") {
Fail-Install "release archive ngit reported an unexpected version: $ngitReported"
}
$remoteReported = Get-BinaryVersion $sources["git-remote-nostr.exe"]
if ($remoteReported -ne "v$version") {
Fail-Install "release archive git-remote-nostr reported an unexpected version: $remoteReported"
}
Install-Binaries $InstallDirectory $sources
Add-UserPath $installDirectory
Show-PathGuidance $installDirectory
Write-Info "Installed ngit v$version to $installDirectory"
}
finally {
Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
# Installer entry point.
try {
Invoke-NgitInstall
}
catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
exit 1
}