mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-26 06:43:15 +08:00
Distilled from #1245 by Andrew Hundt: the two exclusive-reservation helpers and their two call sites, hand-ported onto main's install.ps1. Nothing else from that file is taken -- the PR's copy also carries an older TLS block that would re-break Windows 10 (#1856), so a wholesale take was not an option. Two paths the installer used were guessable, and one of them adopted whatever it found: $TmpDir = Join-Path $env:TEMP "cbm-install-$(Get-Random)" New-Item -ItemType Directory -Path $TmpDir -Force Get-Random is seeded per process and its space is small, and -Force on an existing directory is a no-op that returns it. A directory planted at a guessed name became the staging area -- and the owner-only DACL the installer applies is set only afterwards, on a directory someone else created. The new helper names the directory with a GUID and creates it with -ErrorAction Stop, so a collision is a retry, never an adoption. $InstallerTmp = "$InstallerDest.new" was a fixed path in the install directory, reservable by anyone who could write there first. The sibling is now reserved with FileMode.CreateNew and FileShare.None on an unguessable name, which is atomic, before anything is copied into it. One deliberate difference from the PR: the sibling reservation runs INSIDE the existing try, and the cleanup checks that a path was assigned. That block is documented as best-effort ("a failure here still leaves a working install"), and a reservation that exhausts its 32 attempts should be one more best-effort miss, not a script abort under ErrorActionPreference=Stop. Verified: pure ASCII (PS 5.1 reads a BOM-less .ps1 as ANSI), LF endings matching main, both old call sites gone from live code. NOT verified: this file was not parsed by PowerShell. There is no pwsh on the macOS host, the Windows VM is down, and CI's Windows guards only check that `update` prints the install.ps1 command -- they never execute it. Opened as a draft for that reason; it needs one PowerShell parse before it merges. Co-authored-by: Andrew Hundt <ATHundt@gmail.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
469 lines
20 KiB
PowerShell
469 lines
20 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).
|
|
#
|
|
# TLS 1.3 is added ONLY where schannel can actually negotiate it: Windows 11 and
|
|
# Server 2022 (build 20348+). On Windows 10 the enum value still exists from
|
|
# .NET Framework 4.8 onward, so the assignment succeeds and nothing warns -- but
|
|
# the handshake then fails outright with "The request was aborted: Could not
|
|
# create SSL/TLS secure channel". An unsupported protocol flag in this bitmask
|
|
# is a hard failure, not a graceful downgrade, so every Windows 10 user was
|
|
# blocked at the first download (#1856). The enum-name probe additionally keeps
|
|
# the script parsing on .NET Framework 4.7, where Tls13 is not defined at all.
|
|
$CbmProtocols = [Net.SecurityProtocolType]::Tls12
|
|
if ([Environment]::OSVersion.Version.Build -ge 20348 -and
|
|
([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13')) {
|
|
$CbmProtocols = $CbmProtocols -bor [Net.SecurityProtocolType]::Tls13
|
|
}
|
|
[Net.ServicePointManager]::SecurityProtocol = $CbmProtocols
|
|
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()
|
|
}
|
|
}
|
|
|
|
$SkipConfig = $false
|
|
foreach ($arg in $args) {
|
|
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 " arch: $Arch"
|
|
Write-Host " target: $InstallDir\$BinName"
|
|
Write-Host ""
|
|
|
|
# Reserve a temporary path that no other process can already own.
|
|
#
|
|
# The staging directory used to be "cbm-install-<Get-Random>" created with
|
|
# -Force. Get-Random is seeded per process and its space is small, and -Force
|
|
# ADOPTS a directory that already exists -- so a directory planted at a guessed
|
|
# name became the installer's staging area, ahead of the owner-only DACL that
|
|
# is applied below. GUID names plus -ErrorAction Stop mean a collision is a
|
|
# retry, never an adoption. The same rule applies to the installer's own
|
|
# sibling temp file: "<dest>.new" was a fixed path in the install directory,
|
|
# reservable by anyone who could write there first. FileMode.CreateNew with
|
|
# FileShare.None reserves an unguessable sibling atomically instead.
|
|
function New-CbmExclusiveSiblingTemp {
|
|
param([Parameter(Mandatory=$true)][string]$Destination)
|
|
|
|
$directory = [System.IO.Path]::GetDirectoryName($Destination)
|
|
$leaf = [System.IO.Path]::GetFileName($Destination)
|
|
for ($attempt = 0; $attempt -lt 32; $attempt++) {
|
|
$random = [System.IO.Path]::GetRandomFileName()
|
|
$candidate = Join-Path $directory ".$leaf.tmp-$random"
|
|
try {
|
|
$reservation = [System.IO.File]::Open(
|
|
$candidate,
|
|
[System.IO.FileMode]::CreateNew,
|
|
[System.IO.FileAccess]::Write,
|
|
[System.IO.FileShare]::None)
|
|
$reservation.Dispose()
|
|
return $candidate
|
|
} catch [System.IO.IOException] {
|
|
# A collision belongs to another process; reserve a fresh sibling.
|
|
}
|
|
}
|
|
throw "could not reserve an exclusive temporary sibling for $Destination"
|
|
}
|
|
|
|
function New-CbmExclusiveTempDirectory {
|
|
param([Parameter(Mandatory=$true)][string]$ParentDirectory)
|
|
|
|
for ($attempt = 0; $attempt -lt 32; $attempt++) {
|
|
$candidate = Join-Path $ParentDirectory (
|
|
"cbm-install-" + [guid]::NewGuid().ToString("N")
|
|
)
|
|
try {
|
|
New-Item -ItemType Directory -Path $candidate -ErrorAction Stop | Out-Null
|
|
return $candidate
|
|
} catch [System.IO.IOException] {
|
|
# Never adopt or remove a colliding path owned by another process.
|
|
}
|
|
}
|
|
throw "could not reserve an exclusive installer temporary directory"
|
|
}
|
|
|
|
# Build download URL
|
|
$Archive = "codebase-memory-mcp-windows-$Arch.zip"
|
|
$Url = "$BaseUrl/$Archive"
|
|
|
|
# Download
|
|
$TmpDir = New-CbmExclusiveTempDirectory -ParentDirectory ([System.IO.Path]::GetTempPath())
|
|
|
|
# Give the staging directory a protected owner-only DACL.
|
|
#
|
|
# Without this it inherits whatever %TEMP% carries, and the binary we are about
|
|
# to run from here validates its own directory and refuses inherited
|
|
# cross-account mutation grants. That is not a hypothetical: sandboxed clients
|
|
# leave ACEs on %TEMP% (a CodexSandboxUsers group, AppContainer SIDs, and
|
|
# orphaned SIDs from uninstalled software have all been reported), and installs
|
|
# failed with
|
|
# activation transaction I/O failed: acl-grants-cross-account-mutation to S-1-5-21-...
|
|
# naming an ACE the installer itself inherited. See issues 1529, 1614 and 1571.
|
|
#
|
|
# cbm's own C staging already creates its directory this way; install.ps1 was
|
|
# the one path that did not, which is why redirecting TMP/TEMP worked around it.
|
|
#
|
|
# Applied after creation rather than atomically on purpose: the overload that
|
|
# takes a DirectorySecurity exists on Windows PowerShell 5.1 but not on
|
|
# PowerShell 7, and Set-Acl works on both. The directory name is unpredictable
|
|
# and nothing is written into it until the download below, so the window is not
|
|
# usefully attackable.
|
|
#
|
|
# Best-effort: a filesystem that cannot carry a DACL must not fail the install.
|
|
# If this does not take, the binary's own validation still refuses to proceed,
|
|
# which is the honest outcome rather than a silent downgrade.
|
|
try {
|
|
$stagingAcl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
$stagingAcl.SetAccessRuleProtection($true, $false)
|
|
$stagingOwner = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User
|
|
$stagingAcl.SetOwner($stagingOwner)
|
|
$stagingAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
$stagingOwner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
|
|
Set-Acl -Path $TmpDir -AclObject $stagingAcl -ErrorAction Stop
|
|
} catch {
|
|
Write-Host "note: could not harden the staging directory ACL: $($_.Exception.Message)"
|
|
}
|
|
|
|
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.
|
|
# PS 5.1 wraps redirected native stderr into ErrorRecords, so under the global
|
|
# ErrorActionPreference=Stop a HEALTHY binary that prints one warning while
|
|
# exiting 0 becomes a terminating error here. Relax to Continue for the probe
|
|
# only; failure detection stays on $LASTEXITCODE, and the pre-seed guarantees
|
|
# a binary that fails to START (stale $LASTEXITCODE from an earlier native
|
|
# call) can never read as success.
|
|
try {
|
|
$ProbeEap = $ErrorActionPreference
|
|
try {
|
|
$ErrorActionPreference = "Continue"
|
|
$global:LASTEXITCODE = 1
|
|
$candidateVersion = & $DownloadedBinary --version 2>&1
|
|
} finally {
|
|
$ErrorActionPreference = $ProbeEap
|
|
}
|
|
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"
|
|
# Reserved inside the try: failing to reserve a sibling is a best-effort
|
|
# miss like any other step here, not a reason to abort a finished install.
|
|
$InstallerTmp = $null
|
|
try {
|
|
$InstallerTmp = New-CbmExclusiveSiblingTemp -Destination $InstallerDest
|
|
Copy-Item -LiteralPath $DownloadedInstaller -Destination $InstallerTmp -Force -ErrorAction Stop
|
|
Move-Item -LiteralPath $InstallerTmp -Destination $InstallerDest -Force -ErrorAction Stop
|
|
Write-Host "Installed updater -> $InstallerDest"
|
|
} catch {
|
|
if ($InstallerTmp) {
|
|
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
|
|
# Same PS 5.1 stderr-wrapping guard as the candidate probe above; the pre-seed
|
|
# matters MOST here, because prior successful native calls leave a stale
|
|
# $LASTEXITCODE=0 that a start-failure would otherwise inherit.
|
|
try {
|
|
$ProbeEap = $ErrorActionPreference
|
|
try {
|
|
$ErrorActionPreference = "Continue"
|
|
$global:LASTEXITCODE = 1
|
|
$ver = & $Dest --version 2>&1
|
|
} finally {
|
|
$ErrorActionPreference = $ProbeEap
|
|
}
|
|
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."
|