Files
MercuryToolbox/scripts/install-toolbox.ps1
T

508 lines
16 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
[string]$Configuration = 'ReleaseFast',
[ValidateNotNullOrEmpty()]
[string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'),
[string]$CodexHome,
[switch]$NoPathUpdate,
[switch]$NoCodexSkillInstall,
[switch]$SkipBuild,
[switch]$SkipSlimBinaryRebuild
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'toolbox-commands.ps1')
function Invoke-StrictNative {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string[]]$ArgumentList
)
& $FilePath @ArgumentList
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')"
}
}
function Get-RequiredCommandPath {
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
$command = Get-Command -Name $Name -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "Required command not found on PATH: $Name"
}
return $command.Source
}
function Normalize-PathValue {
param(
[AllowNull()]
[string]$PathValue
)
if ([string]::IsNullOrWhiteSpace($PathValue)) {
return ''
}
return $PathValue.Replace('/', '\').Trim().TrimEnd('\').ToLowerInvariant()
}
function Split-PathEntries {
param(
[AllowNull()]
[string]$PathValue
)
if ([string]::IsNullOrWhiteSpace($PathValue)) {
return @()
}
return $PathValue.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries)
}
function Add-PathEntry {
param(
[AllowNull()]
[string]$ExistingPath,
[Parameter(Mandatory = $true)]
[string]$Entry
)
$normalizedEntry = Normalize-PathValue -PathValue $Entry
$entries = Split-PathEntries -PathValue $ExistingPath
foreach ($existing in $entries) {
if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) {
return @{
Changed = $false
Value = $ExistingPath
}
}
}
$newEntries = @($entries + $Entry)
return @{
Changed = $true
Value = ($newEntries -join ';')
}
}
function Remove-PathEntry {
param(
[AllowNull()]
[string]$ExistingPath,
[Parameter(Mandatory = $true)]
[string]$Entry
)
$normalizedEntry = Normalize-PathValue -PathValue $Entry
$remaining = [System.Collections.Generic.List[string]]::new()
$changed = $false
foreach ($existing in (Split-PathEntries -PathValue $ExistingPath)) {
if ((Normalize-PathValue -PathValue $existing) -eq $normalizedEntry) {
$changed = $true
continue
}
$remaining.Add($existing)
}
return @{
Changed = $changed
Value = ($remaining -join ';')
}
}
function Resolve-CodexHomePath {
param(
[AllowNull()]
[string]$ExplicitCodexHome
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitCodexHome)) {
return $ExplicitCodexHome
}
if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) {
return $env:CODEX_HOME
}
return (Join-Path $env:USERPROFILE '.codex')
}
function Write-CodexSkillOwnershipMarker {
param(
[Parameter(Mandatory = $true)]
[string]$SkillRoot,
[Parameter(Mandatory = $true)]
[string]$InstallKind
)
$markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json'
$payload = [ordered]@{
owner = 'mercury-toolbox'
install_kind = $InstallKind
installed_utc = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Compress
Set-Content -LiteralPath $markerPath -Value $payload -Encoding utf8NoBOM
}
function Assert-CodexSkillInstallAllowed {
param(
[AllowNull()]
[string]$SkillRoot
)
if ([string]::IsNullOrWhiteSpace($SkillRoot) -or -not (Test-Path -LiteralPath $SkillRoot)) {
return
}
$markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json'
if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) {
throw "Refusing to overwrite unmanaged Codex skill directory: $SkillRoot"
}
try {
$marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json
}
catch {
throw "Refusing to overwrite Codex skill directory with invalid ownership marker: $SkillRoot"
}
if ($marker.owner -ne 'mercury-toolbox') {
throw "Refusing to overwrite Codex skill directory owned by '$($marker.owner)': $SkillRoot"
}
}
function Copy-OptionalFile {
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[string]$DestinationPath
)
if (Test-Path -LiteralPath $SourcePath) {
$destinationDirectory = Split-Path -Parent $DestinationPath
if (-not [string]::IsNullOrWhiteSpace($destinationDirectory)) {
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
}
Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force
}
}
function Assert-ExistingPathNotReparsePoint {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Description
)
$resolvedPath = [System.IO.Path]::GetFullPath($Path)
if (-not (Test-Path -LiteralPath $resolvedPath)) {
return
}
$item = Get-Item -LiteralPath $resolvedPath -Force
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Description must not be a reparse point: $resolvedPath"
}
}
function Assert-ExistingTreeHasNoReparsePoints {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Description
)
$resolvedPath = [System.IO.Path]::GetFullPath($Path)
if (-not (Test-Path -LiteralPath $resolvedPath)) {
return
}
$reparsePoints = @(Get-ChildItem -LiteralPath $resolvedPath -Force -Recurse -Attributes ReparsePoint)
if ($reparsePoints.Count -gt 0) {
throw "$Description must not contain reparse points: $($reparsePoints[0].FullName)"
}
}
function Copy-DirectoryTree {
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[string]$DestinationPath
)
if (-not (Test-Path -LiteralPath $SourcePath)) {
throw "Required directory not found: $SourcePath"
}
$sourceRoot = Get-Item -LiteralPath $SourcePath -Force
if (($sourceRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Refusing to copy reparse-point directory: $SourcePath"
}
$reparsePoints = @(Get-ChildItem -LiteralPath $SourcePath -Force -Recurse -Attributes ReparsePoint)
if ($reparsePoints.Count -gt 0) {
throw "Refusing to copy directory tree containing reparse points: $($reparsePoints[0].FullName)"
}
if (Test-Path -LiteralPath $DestinationPath) {
Remove-Item -LiteralPath $DestinationPath -Recurse -Force
}
$destinationParent = Split-Path -Parent $DestinationPath
if (-not [string]::IsNullOrWhiteSpace($destinationParent)) {
New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null
}
Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force
}
function Remove-PathItem {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
$item = Get-Item -LiteralPath $Path -Force
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
Remove-Item -LiteralPath $Path -Force
return
}
if ($item.PSIsContainer) {
Remove-Item -LiteralPath $Path -Recurse -Force
return
}
Remove-Item -LiteralPath $Path -Force
}
function New-VersionDirectoryName {
param(
[Parameter(Mandatory = $true)]
[string]$Configuration
)
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
$suffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 8)
return "$stamp-$($Configuration.ToLowerInvariant())-$suffix"
}
function Set-CurrentInstallLink {
param(
[Parameter(Mandatory = $true)]
[string]$CurrentLinkPath,
[Parameter(Mandatory = $true)]
[string]$TargetPath
)
$currentParent = Split-Path -Parent $CurrentLinkPath
if (-not [string]::IsNullOrWhiteSpace($currentParent)) {
New-Item -ItemType Directory -Force -Path $currentParent | Out-Null
}
if (Test-Path -LiteralPath $CurrentLinkPath) {
Remove-PathItem -Path $CurrentLinkPath
}
try {
New-Item -ItemType Junction -Path $CurrentLinkPath -Target $TargetPath | Out-Null
}
catch {
throw "Failed to create current install junction '$CurrentLinkPath' -> '$TargetPath'. Ensure this is running on Windows with a local NTFS-compatible install root. $($_.Exception.Message)"
}
if (-not (Test-Path -LiteralPath $CurrentLinkPath)) {
throw "Current install junction was not created: $CurrentLinkPath"
}
}
function Remove-ObsoleteVersionDirectories {
param(
[Parameter(Mandatory = $true)]
[string]$VersionsRoot,
[AllowNull()]
[string]$CurrentVersionRoot
)
if (-not (Test-Path -LiteralPath $VersionsRoot)) {
return
}
foreach ($directory in Get-ChildItem -LiteralPath $VersionsRoot -Directory -Force) {
if (-not [string]::IsNullOrWhiteSpace($CurrentVersionRoot) -and $directory.FullName -eq $CurrentVersionRoot) {
continue
}
try {
Remove-Item -LiteralPath $directory.FullName -Recurse -Force
}
catch {
Write-Warning "Leaving stale toolbox version in place because it is still in use: $($directory.FullName)"
}
}
}
$workspaceRoot = Split-Path -Parent $PSScriptRoot
$profileName = Resolve-ToolboxProfileName -Configuration $Configuration
$legacyBinDir = Join-Path $InstallRoot 'bin'
$versionsRoot = Join-Path $InstallRoot 'versions'
$currentRoot = Join-Path $InstallRoot 'current'
$activeBinDir = Join-Path $currentRoot 'bin'
$versionRoot = Join-Path $versionsRoot (New-VersionDirectoryName -Configuration $Configuration)
$stagingBinDir = Join-Path $versionRoot 'bin'
$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox'
$codexHomeRoot = if ($NoCodexSkillInstall) { $null } else { Resolve-CodexHomePath -ExplicitCodexHome $CodexHome }
$codexSkillRoot = if ([string]::IsNullOrWhiteSpace([string]$codexHomeRoot)) { $null } else { Join-Path $codexHomeRoot 'skills\mercury-toolbox' }
$legacyCommandNames = @('context', 'waitfor')
Assert-ExistingPathNotReparsePoint -Path $InstallRoot -Description 'InstallRoot'
Assert-ExistingPathNotReparsePoint -Path $versionsRoot -Description 'versions root'
Assert-ExistingPathNotReparsePoint -Path $shareRoot -Description 'share root'
Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root'
if ($null -ne $codexSkillRoot) {
Assert-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root'
Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root'
}
Assert-CodexSkillInstallAllowed -SkillRoot $codexSkillRoot
Write-Host "Mercury Toolbox installer"
Write-Host "workspace: $workspaceRoot"
Write-Host "install root: $InstallRoot"
Write-Host "active bin dir: $activeBinDir"
Write-Host "versions root: $versionsRoot"
Write-Host "share dir: $shareRoot"
if ($null -ne $codexSkillRoot) {
Write-Host "Codex skill dir: $codexSkillRoot"
}
Write-Host "configuration: $Configuration"
New-Item -ItemType Directory -Force -Path $stagingBinDir, $shareRoot | Out-Null
foreach ($legacyName in $legacyCommandNames) {
$legacyPath = Join-Path $legacyBinDir "$legacyName.exe"
if (Test-Path -LiteralPath $legacyPath) {
Remove-Item -LiteralPath $legacyPath -Force
}
}
if (-not $SkipBuild) {
$cargoPath = Get-RequiredCommandPath -Name 'cargo'
Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration -SkipSlimBinaryRebuild:$SkipSlimBinaryRebuild
}
$promptScript = Join-Path $PSScriptRoot 'generate-ai-prompt.ps1'
& $promptScript -Configuration $Configuration -SkipBuild
if ($LASTEXITCODE -ne 0) {
throw "generate-ai-prompt.ps1 exited with $LASTEXITCODE"
}
$skillScript = Join-Path $PSScriptRoot 'generate-ai-skill.ps1'
& $skillScript -Configuration $Configuration -SkipBuild
if ($LASTEXITCODE -ne 0) {
throw "generate-ai-skill.ps1 exited with $LASTEXITCODE"
}
$installed = [System.Collections.Generic.List[string]]::new()
foreach ($commandName in Get-ToolboxCommandNames) {
$sourcePath = Join-Path $workspaceRoot "target\$profileName\$commandName.exe"
if (-not (Test-Path -LiteralPath $sourcePath)) {
throw "Expected binary not found: $sourcePath"
}
$destinationPath = Join-Path $stagingBinDir "$commandName.exe"
Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force
$installed.Add($commandName)
}
$duckdbDllPath = Join-Path $workspaceRoot "target\$profileName\deps\duckdb.dll"
if (Test-Path -LiteralPath $duckdbDllPath) {
Copy-Item -LiteralPath $duckdbDllPath -Destination (Join-Path $stagingBinDir 'duckdb.dll') -Force
}
Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'docs\ai') -DestinationPath (Join-Path $shareRoot 'docs\ai')
Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath (Join-Path $shareRoot 'skills\mercury-toolbox')
if (Test-Path -LiteralPath (Join-Path $workspaceRoot 'support')) {
Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'support') -DestinationPath (Join-Path $shareRoot 'support')
}
Copy-OptionalFile -SourcePath (Join-Path $workspaceRoot 'README.md') -DestinationPath (Join-Path $shareRoot 'README.md')
if ($null -ne $codexSkillRoot) {
Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath $codexSkillRoot
Write-CodexSkillOwnershipMarker -SkillRoot $codexSkillRoot -InstallKind 'workspace'
}
Set-CurrentInstallLink -CurrentLinkPath $currentRoot -TargetPath $versionRoot
Remove-ObsoleteVersionDirectories -VersionsRoot $versionsRoot -CurrentVersionRoot $versionRoot
$pathStatus = 'skipped'
if (-not $NoPathUpdate) {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$userPathChanged = $false
$userPathRemoval = Remove-PathEntry -ExistingPath $userPath -Entry $legacyBinDir
if ($userPathRemoval.Changed) {
$userPath = $userPathRemoval.Value
$userPathChanged = $true
}
$userPathAdd = Add-PathEntry -ExistingPath $userPath -Entry $activeBinDir
if ($userPathAdd.Changed) {
$userPath = $userPathAdd.Value
$userPathChanged = $true
}
if ($userPathChanged) {
[Environment]::SetEnvironmentVariable('Path', $userPath, 'User')
$pathStatus = 'updated'
}
else {
$pathStatus = 'already_present'
}
$sessionPath = $env:Path
$sessionRemoval = Remove-PathEntry -ExistingPath $sessionPath -Entry $legacyBinDir
if ($sessionRemoval.Changed) {
$sessionPath = $sessionRemoval.Value
}
$sessionAdd = Add-PathEntry -ExistingPath $sessionPath -Entry $activeBinDir
if ($sessionAdd.Changed) {
$sessionPath = $sessionAdd.Value
}
$env:Path = $sessionPath
}
Write-Host ''
Write-Host "Installed commands ($($installed.Count)):"
foreach ($commandName in $installed) {
Write-Host " - $commandName"
}
Write-Host "PATH status: $pathStatus"
Write-Host "AI prompt: $(Join-Path $shareRoot 'docs\ai\mercury-toolbox-ai-prompt.md')"
Write-Host "Command catalog: $(Join-Path $shareRoot 'skills\mercury-toolbox\references\command-catalog.md')"
Write-Host "Shared skill copy: $(Join-Path $shareRoot 'skills\mercury-toolbox\SKILL.md')"
if ($null -ne $codexSkillRoot) {
Write-Host "Codex skill: $(Join-Path $codexSkillRoot 'SKILL.md')"
}
if (-not $NoPathUpdate) {
Write-Host "Shell note: future shells pick up the new PATH automatically."
Write-Host "Shell note: if you launched install via 'pwsh -File', reopen this shell or run:"
Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine')"
}
Write-Host 'Verification: pathshadow msudo'
Write-Host 'Verification: msudo status --json'
Write-Host 'Verification: msudo --help'