Files
Martin Vogel afd67c51b0 refactor(cli): install scripts own updating; the binary carries no URL
Completes the move started on Windows. `update` prints the install script's
command and exits 0 on every platform; the download/extract/chmod/exec sequence
is compiled out of release builds entirely, and the MCP background thread that
ran cbm_popen("curl ... api.github.com ...") on first tool call is gone.

The installers now place themselves beside the binary, which closes the last
gap: `update` referenced a raw.githubusercontent URL purely because an
install.sh-installed user had no local copy to point at. Two details make that
safe.

The source is the install script from the archive we JUST checksum-verified,
never "$0" -- which does not exist under `curl | bash`, and would pin the user
to the OLD installer forever. Since the script ships inside the verified
archive, it inherits that verification instead of needing its own.

And it is published by atomic rename, never written over the live path. Bash
reads a script incrementally by byte offset, so overwriting the file it is
executing continues reading NEW bytes at the OLD offset: silent, bizarre
corruption. cp to a temp name then mv -f swaps the directory entry while the
running shell keeps its original inode. Windows follows the same rule via
Copy-Item + Move-Item even though PowerShell parses up front.

Deliberately NOT done: spawning the new installer to delete and replace its
predecessor. Fetch remote code -> drop to disk -> spawn -> self-delete is a
textbook stager, and self-deletion is among the most heavily weighted
heuristics there is. It also gains nothing -- `"$DLBIN" install` already runs
the NEW release's install logic, because the binary owns the install.

Net: zero install or download URLs remain in the shipped binary, and the
allow-list entry is retired with it. Both scripts scan CLEAN (0/61) with the
self-placement code in them.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 17:04:50 +02:00

351 lines
15 KiB
PowerShell

# install.ps1 - One-line installer for codebase-memory-mcp (Windows).
#
# Usage: see README.md for install instructions.
#
# Environment:
# CBM_DOWNLOAD_URL Override base URL for downloads (for testing)
$ErrorActionPreference = "Stop"
# Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0 which GitHub rejects)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
Add-Type -AssemblyName System.Net.Http
$Repo = "DeusData/codebase-memory-mcp"
$InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp"
$BinName = "codebase-memory-mcp.exe"
$WindowsArchiveNames = @(
$BinName,
"LICENSE",
"install.ps1",
"THIRD_PARTY_NOTICES.md"
)
$BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" }
try { $BaseUri = [Uri]$BaseUrl } catch { $BaseUri = $null }
$AllowLoopbackHttp = (
$BaseUri -and $BaseUri.IsAbsoluteUri -and
$BaseUri.Scheme -eq "http" -and $BaseUri.IsLoopback -and
[string]::IsNullOrEmpty($BaseUri.UserInfo)
)
if (-not $BaseUri -or -not $BaseUri.IsAbsoluteUri -or
($BaseUri.Scheme -ne "https" -and -not $AllowLoopbackHttp) -or
-not [string]::IsNullOrEmpty($BaseUri.UserInfo)) {
Write-Host "error: refusing non-HTTPS download URL: $BaseUrl" -ForegroundColor Red
exit 1
}
function Invoke-CbmDownload {
param([Parameter(Mandatory=$true)][string]$Url,
[Parameter(Mandatory=$true)][string]$OutFile)
$current = [Uri]$Url
$handler = New-Object System.Net.Http.HttpClientHandler
$handler.AllowAutoRedirect = $false
$client = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $handler
$client.Timeout = [TimeSpan]::FromMinutes(10)
try {
for ($redirects = 0; $redirects -le 5; $redirects++) {
$allowed = $current.IsAbsoluteUri -and
[string]::IsNullOrEmpty($current.UserInfo) -and
($current.Scheme -eq "https" -or
($AllowLoopbackHttp -and $current.Scheme -eq "http" -and
$current.IsLoopback))
if (-not $allowed) {
throw "download redirect escaped the allowed transport: $current"
}
$response = $client.GetAsync(
$current, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
).GetAwaiter().GetResult()
try {
$status = [int]$response.StatusCode
if ($status -in @(301, 302, 303, 307, 308)) {
if ($redirects -eq 5 -or -not $response.Headers.Location) {
throw "invalid or excessive download redirect from $current"
}
$current = [Uri]::new($current, $response.Headers.Location)
continue
}
if (-not $response.IsSuccessStatusCode) {
throw "HTTP $status for $current"
}
$input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
try {
$output = [System.IO.File]::Open(
$OutFile, [System.IO.FileMode]::Create,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None)
try { $input.CopyTo($output) } finally { $output.Dispose() }
} finally { $input.Dispose() }
return
} finally { $response.Dispose() }
}
throw "too many download redirects"
} finally {
$client.Dispose()
$handler.Dispose()
}
}
# Detect variant from args (--ui or --standard)
$Variant = "standard"
$SkipConfig = $false
foreach ($arg in $args) {
if ($arg -eq "--ui") { $Variant = "ui" }
if ($arg -eq "--standard") { $Variant = "standard" }
if ($arg -eq "--skip-config") { $SkipConfig = $true }
if ($arg -like "--dir=*") { $InstallDir = $arg.Substring(6) }
}
# Detect the OS architecture. RuntimeInformation.OSArchitecture reports the real
# OS arch (Arm64) even from an x64 process running under emulation on ARM64 --
# unlike $env:PROCESSOR_ARCHITECTURE, which reports the emulated "AMD64", and
# PROCESSOR_ARCHITEW6432, which is unset for 64-bit emulated processes. Fall back
# to the env vars only if the .NET API is somehow unavailable.
if ($env:CBM_ARCH) {
# Explicit override wins - used by CI/tests, and an escape hatch under x64
# emulation on ARM64 where no in-process detection is reliable.
$Arch = $env:CBM_ARCH
} else {
try {
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
$Arch = if ($osArch -eq 'Arm64') { "arm64" } else { "amd64" }
} catch {
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
$Arch = "arm64"
} else {
$Arch = "amd64"
}
}
}
Write-Host "codebase-memory-mcp installer (Windows)"
Write-Host " variant: $Variant"
Write-Host " arch: $Arch"
Write-Host " target: $InstallDir\$BinName"
Write-Host ""
# Build download URL
if ($Variant -eq "ui") {
$Archive = "codebase-memory-mcp-ui-windows-$Arch.zip"
} else {
$Archive = "codebase-memory-mcp-windows-$Arch.zip"
}
$Url = "$BaseUrl/$Archive"
# Download
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-install-$(Get-Random)"
New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null
Write-Host "Downloading $Archive..."
try {
Invoke-CbmDownload -Url $Url -OutFile "$TmpDir\$Archive"
} catch {
Write-Host "error: download failed: $_" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
# Checksum verification is mandatory. Do not request coordinated shutdown for
# a candidate that was not positively matched to the published release digest.
$ChecksumUrl = "$BaseUrl/checksums.txt"
try {
Invoke-CbmDownload -Url $ChecksumUrl -OutFile "$TmpDir\checksums.txt"
$checksumPath = "$TmpDir\checksums.txt"
if ((Get-Item -LiteralPath $checksumPath).Length -gt 1048576) {
throw "checksums.txt exceeds the 1 MiB safety limit"
}
$checksumLines = @(Get-Content -LiteralPath $checksumPath | Where-Object {
$parts = $_ -split '\s+'
$parts.Count -ge 2 -and $parts[1].TrimStart('*') -eq $Archive
})
if ($checksumLines.Count -eq 0) {
throw "no digest for $Archive in checksums.txt"
}
$expected = $null
foreach ($checksumLine in $checksumLines) {
$digest = (($checksumLine -split '\s+')[0]).ToLower()
if ($digest -notmatch '^[0-9a-f]{64}$') {
throw "invalid SHA-256 digest for $Archive"
}
if ($null -ne $expected -and $expected -ne $digest) {
throw "conflicting SHA-256 digests for $Archive"
}
$expected = $digest
}
$actual = (Get-FileHash -Path "$TmpDir\$Archive" -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) {
throw "CHECKSUM MISMATCH (expected $expected, actual $actual)"
}
Write-Host "Checksum verified."
} catch {
Write-Host "error: checksum verification failed: $_" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
# Validate the zip namespace before extraction. Windows paths are
# case-insensitive, so two entries that differ only in case are ambiguous and
# must never be allowed to overwrite each other. The official five entries are
# required at the archive root with their exact release names.
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive")
try {
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$archiveCounts = @{}
foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 }
foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace('\', '/')
$isDirectory = $entryName.EndsWith('/')
$pathForSegments = if ($isDirectory) { $entryName.TrimEnd('/') } else { $entryName }
$segments = @($pathForSegments.Split('/'))
if ([string]::IsNullOrEmpty($pathForSegments) -or
$entryName.StartsWith('/') -or
$entryName.Contains(':') -or
$segments -contains '' -or
$segments -contains '.' -or
$segments -contains '..' -or
@($segments | Where-Object { $_.EndsWith('.') -or $_.EndsWith(' ') }).Count -gt 0) {
throw "unsafe zip entry path: $($entry.FullName)"
}
if (-not $seen.Add($pathForSegments)) {
throw "duplicate or case-conflicting zip entry: $($entry.FullName)"
}
if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) {
throw "archive contains an unexpected root entry: $($entry.FullName)"
}
$archiveCounts[$entryName] = $archiveCounts[$entryName] + 1
}
foreach ($archiveName in $WindowsArchiveNames) {
if ($archiveCounts[$archiveName] -ne 1) {
throw "archive must contain exactly one $archiveName"
}
}
if ($seen.Count -ne $WindowsArchiveNames.Count) {
throw "archive does not match the exact Windows release allowlist"
}
} finally {
$zip.Dispose()
}
} catch {
Write-Host "error: unsafe or incomplete release archive: $_" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
# Extract the validated bundle. Windows ships ONE binary, exactly like Linux
# and macOS; this script is what replaces it, because a running executable
# cannot replace itself on Windows. Re-running this script IS the update.
Write-Host "Extracting..."
Expand-Archive -Path "$TmpDir\$Archive" -DestinationPath $TmpDir -Force
$DownloadedBinary = Join-Path $TmpDir $BinName
if (-not (Test-Path -LiteralPath $DownloadedBinary -PathType Leaf)) {
Write-Host "error: $BinName not found after extraction" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir
exit 1
}
$binaryItem = Get-Item -LiteralPath $DownloadedBinary
if ($binaryItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
Write-Host "error: refusing reparse-point executable in release archive" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir
exit 1
}
# Prove the downloaded binary runs before touching an existing installation.
try {
$candidateVersion = & $DownloadedBinary --version 2>&1
if ($LASTEXITCODE -ne 0) { throw "candidate exited with $LASTEXITCODE" }
Write-Host "Verified candidate: $candidateVersion"
} catch {
Write-Host "error: downloaded binary failed to run: $_" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
$Dest = Join-Path $InstallDir $BinName
# Retire the running installation before replacing it. Windows keeps an image
# lock on a running .exe: the file cannot be overwritten, but it CAN be renamed
# out of the way, which is what makes an in-place update possible from here.
if (Test-Path -LiteralPath $Dest -PathType Leaf) {
try { & $Dest daemon stop 2>&1 | Out-Null } catch { }
$retired = "$Dest.retired-$(Get-Date -Format yyyyMMddHHmmss)"
$renamed = $false
foreach ($attempt in 1..10) {
try { Move-Item -LiteralPath $Dest -Destination $retired -Force -ErrorAction Stop; $renamed = $true; break }
catch { Start-Sleep -Milliseconds 500 }
}
if (-not $renamed) {
Write-Host "error: could not retire the existing $BinName - close all running" -ForegroundColor Red
Write-Host " codebase-memory-mcp sessions and coding agents, then re-run." -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
# A retired image stays locked until its last process exits; delete it when
# we can, and leave it for the next run when we cannot. Never fail here.
Remove-Item -LiteralPath $retired -Force -ErrorAction SilentlyContinue
}
Get-ChildItem -LiteralPath $InstallDir -Filter "$BinName.retired-*" -ErrorAction SilentlyContinue |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
$InstallArgs = @("install", "-y", "--force", "--dir=$InstallDir")
if ($SkipConfig) { $InstallArgs += "--skip-config" }
& $DownloadedBinary @InstallArgs
if ($LASTEXITCODE -ne 0) {
Write-Host "error: installation failed (exit code $LASTEXITCODE)" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
# Place the installer beside the binary so `update` points at a local file
# rather than a URL, and so the next update runs THIS release's installer.
#
# Sourced from the archive we just checksum-verified, and published by rename
# rather than written over the live path. PowerShell parses a script fully
# before executing it, so self-overwrite is less hazardous here than it is for
# bash -- but rename costs nothing and keeps both platforms on one rule.
# Best effort: a failure here still leaves a working install.
$DownloadedInstaller = Join-Path $TmpDir "install.ps1"
if (Test-Path -LiteralPath $DownloadedInstaller -PathType Leaf) {
$InstallerDest = Join-Path $InstallDir "install.ps1"
$InstallerTmp = "$InstallerDest.new"
try {
Copy-Item -LiteralPath $DownloadedInstaller -Destination $InstallerTmp -Force -ErrorAction Stop
Move-Item -LiteralPath $InstallerTmp -Destination $InstallerDest -Force -ErrorAction Stop
Write-Host "Installed updater -> $InstallerDest"
} catch {
Remove-Item -LiteralPath $InstallerTmp -Force -ErrorAction SilentlyContinue
Write-Host "note: could not place install.ps1 in $InstallDir (update will explain where to find it)"
}
}
# Verify
try {
$ver = & $Dest --version 2>&1
if ($LASTEXITCODE -ne 0) { throw "installed binary exited with $LASTEXITCODE" }
Write-Host "Installed: $ver"
} catch {
Write-Host "error: installed binary failed to run" -ForegroundColor Red
Remove-Item -Recurse -Force $TmpDir
exit 1
}
# Agent configuration was included in the candidate-owned activation window.
if ($SkipConfig) {
Write-Host ""
Write-Host "Skipping agent configuration (--skip-config)"
}
# The verified candidate persisted the current-user PATH while holding the
# coordinated activation lease. Do not perform a second registry mutation here
# after running sessions have been allowed to restart.
# Cleanup
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "Done! Restart your terminal and coding agent to start using codebase-memory-mcp."