forked from Crockan/MercuryToolbox
chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DigestPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\release-fast\mhash.exe'),
|
||||
[string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\mhash-benchmark\smoke'),
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Assert-Smoke {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool]$Condition,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
if (-not $Condition) {
|
||||
throw "Smoke assertion failed: $Message"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SingleLatestFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Root,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Filter
|
||||
)
|
||||
|
||||
$file = Get-ChildItem -LiteralPath $Root -Filter $Filter -File |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
Assert-Smoke -Condition ($null -ne $file) -Message "Expected $Filter under $Root."
|
||||
return $file
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
$harness = Join-Path $PSScriptRoot 'benchmark-mhash.ps1'
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$smokeRoot = Join-Path $OutputRoot $stamp
|
||||
$runRoot = Join-Path $smokeRoot 'results'
|
||||
$dataRoot = Join-Path $smokeRoot 'data'
|
||||
|
||||
$benchmarkArgs = @{
|
||||
DataRoot = $dataRoot
|
||||
OutputRoot = $runRoot
|
||||
Sizes = @('1KiB')
|
||||
Algorithms = @('sha256')
|
||||
Repeat = 1
|
||||
Warmup = 0
|
||||
DigestPath = $DigestPath
|
||||
NoExternal = $true
|
||||
DigestFormats = @('sum', 'jsonl')
|
||||
}
|
||||
if ($SkipBuild) {
|
||||
$benchmarkArgs.SkipBuild = $true
|
||||
}
|
||||
|
||||
& $harness @benchmarkArgs
|
||||
|
||||
$rawPath = Get-SingleLatestFile -Root $runRoot -Filter '*.raw.json'
|
||||
$summaryPath = Get-SingleLatestFile -Root $runRoot -Filter '*.summary.json'
|
||||
$recordsJsonlPath = Get-SingleLatestFile -Root $runRoot -Filter '*.records.jsonl'
|
||||
$summaryJsonlPath = Get-SingleLatestFile -Root $runRoot -Filter '*.summary.jsonl'
|
||||
$artifactsPath = Get-SingleLatestFile -Root $runRoot -Filter '*.artifacts.json'
|
||||
|
||||
$raw = Read-JsonFile -Path $rawPath.FullName
|
||||
$summary = @(Read-JsonFile -Path $summaryPath.FullName)
|
||||
$artifacts = @(Read-JsonFile -Path $artifactsPath.FullName)
|
||||
$records = @($raw.records)
|
||||
$recordsJsonlLines = @(Get-Content -LiteralPath $recordsJsonlPath.FullName)
|
||||
$summaryJsonlLines = @(Get-Content -LiteralPath $summaryJsonlPath.FullName)
|
||||
|
||||
Assert-Smoke -Condition ($raw.environment.mhash_formats.Count -eq 2) -Message 'Expected both sum and jsonl mhash formats in environment metadata.'
|
||||
Assert-Smoke -Condition ($raw.environment.size_only -eq $false) -Message 'Expected normal benchmark mode in raw environment metadata.'
|
||||
Assert-Smoke -Condition ($artifacts.Count -ge 1) -Message 'Expected at least one tool artifact record.'
|
||||
Assert-Smoke -Condition ($records.Count -eq 4) -Message "Expected 4 measured records, got $($records.Count)."
|
||||
Assert-Smoke -Condition ($summary.Count -eq 4) -Message "Expected 4 summary records, got $($summary.Count)."
|
||||
Assert-Smoke -Condition ($recordsJsonlLines.Count -eq $records.Count) -Message 'Records JSONL line count should match raw record count.'
|
||||
Assert-Smoke -Condition ($summaryJsonlLines.Count -eq $summary.Count) -Message 'Summary JSONL line count should match summary count.'
|
||||
|
||||
foreach ($record in $records) {
|
||||
Assert-Smoke -Condition ($record.binary_size_bytes -gt 0) -Message 'Each measured record should include binary_size_bytes.'
|
||||
Assert-Smoke -Condition (-not [string]::IsNullOrWhiteSpace($record.output_format)) -Message 'Each measured record should include output_format.'
|
||||
Assert-Smoke -Condition ($record.peak_working_set_bytes -gt 0) -Message 'Each measured record should include peak_working_set_bytes.'
|
||||
}
|
||||
|
||||
foreach ($row in $summary) {
|
||||
Assert-Smoke -Condition ($row.binary_size_bytes -gt 0) -Message 'Each summary row should include binary_size_bytes.'
|
||||
Assert-Smoke -Condition (-not [string]::IsNullOrWhiteSpace($row.output_format)) -Message 'Each summary row should include output_format.'
|
||||
}
|
||||
|
||||
$sizeOnlyRoot = Join-Path $smokeRoot 'size-only'
|
||||
$sizeOnlyArgs = @{
|
||||
DataRoot = (Join-Path $smokeRoot 'size-only-data')
|
||||
OutputRoot = $sizeOnlyRoot
|
||||
Sizes = @('1KiB')
|
||||
Algorithms = @('sha256', 'blake3-256', 'blake2sp', 'xxh3-64', 'xxh3-128', 'crc32', 'crc64-xz', 'k12-256', 'parallelhash256-528')
|
||||
Repeat = 1
|
||||
Warmup = 0
|
||||
DigestPath = $DigestPath
|
||||
SkipBuild = $true
|
||||
NoExternal = $true
|
||||
SizeOnly = $true
|
||||
}
|
||||
|
||||
& $harness @sizeOnlyArgs
|
||||
|
||||
$sizeOnlyRawPath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.raw.json'
|
||||
$sizeOnlySummaryPath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.summary.json'
|
||||
$sizeOnlyHyperfinePath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.hyperfine.json'
|
||||
$sizeOnlyArtifactsPath = Get-SingleLatestFile -Root $sizeOnlyRoot -Filter '*.artifacts.json'
|
||||
$sizeOnlyRaw = Read-JsonFile -Path $sizeOnlyRawPath.FullName
|
||||
$sizeOnlySummary = @(Read-JsonFile -Path $sizeOnlySummaryPath.FullName)
|
||||
$sizeOnlyHyperfine = @(Read-JsonFile -Path $sizeOnlyHyperfinePath.FullName)
|
||||
$sizeOnlyArtifacts = @(Read-JsonFile -Path $sizeOnlyArtifactsPath.FullName)
|
||||
|
||||
Assert-Smoke -Condition ($sizeOnlyRaw.environment.size_only -eq $true) -Message 'Expected size-only mode in raw environment metadata.'
|
||||
Assert-Smoke -Condition ($sizeOnlyRaw.environment.algorithms -contains 'blake3-256') -Message 'Size-only smoke should accept extended mhash benchmark algorithms.'
|
||||
Assert-Smoke -Condition ($sizeOnlyRaw.environment.algorithms -contains 'parallelhash256-528') -Message 'Size-only smoke should accept ParallelHash mhash benchmark algorithms.'
|
||||
Assert-Smoke -Condition (@($sizeOnlyRaw.records).Count -eq 0) -Message 'Size-only mode should not emit measured records.'
|
||||
Assert-Smoke -Condition ($sizeOnlySummary.Count -eq 0) -Message 'Size-only summary JSON should be an empty array.'
|
||||
Assert-Smoke -Condition ($sizeOnlyHyperfine.Count -eq 0) -Message 'Size-only hyperfine JSON should be an empty array.'
|
||||
Assert-Smoke -Condition ($sizeOnlyArtifacts.Count -ge 1) -Message 'Size-only mode should still emit artifact telemetry.'
|
||||
|
||||
Write-Host "Smoke benchmark raw: $($rawPath.FullName)"
|
||||
Write-Host "Smoke size-only raw: $($sizeOnlyRawPath.FullName)"
|
||||
Write-Host "Smoke assertions passed: records=$($records.Count), summary=$($summary.Count), artifacts=$($artifacts.Count)"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Package,
|
||||
[string]$Binary,
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$BuildProfile = 'Release',
|
||||
[string]$Output,
|
||||
[string]$LogPath,
|
||||
[string]$WorkspaceRoot = (Split-Path -Parent $PSScriptRoot),
|
||||
[switch]$NoElevate,
|
||||
[switch]$Describe,
|
||||
[string]$TargetArgumentJson,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$TargetArgument = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Convert-ToSingleQuotedLiteral {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
return "'{0}'" -f $Value.Replace("'", "''")
|
||||
}
|
||||
|
||||
function Convert-ToEncodedCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Command
|
||||
)
|
||||
|
||||
return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command))
|
||||
}
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$currentPrincipal = [Security.Principal.WindowsPrincipal]::new($currentIdentity)
|
||||
return $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Write-RelayLog {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($LogPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
$logDirectory = Split-Path -Parent $LogPath
|
||||
if (-not [string]::IsNullOrWhiteSpace($logDirectory)) {
|
||||
New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'O'
|
||||
$line = "[$timestamp] $Message{0}" -f [Environment]::NewLine
|
||||
$utf8NoBom = [Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::AppendAllText($LogPath, $line, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-ElevationHostPath {
|
||||
$currentProcess = Get-Process -Id $PID -ErrorAction SilentlyContinue
|
||||
if ($null -ne $currentProcess -and -not [string]::IsNullOrWhiteSpace($currentProcess.Path)) {
|
||||
return $currentProcess.Path
|
||||
}
|
||||
|
||||
$pwshCommand = Get-Command -Name 'pwsh.exe' -ErrorAction SilentlyContinue
|
||||
if ($null -ne $pwshCommand) {
|
||||
return $pwshCommand.Source
|
||||
}
|
||||
|
||||
return 'powershell.exe'
|
||||
}
|
||||
|
||||
function Get-TranscriptPath {
|
||||
if ([string]::IsNullOrWhiteSpace($LogPath)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return '{0}.transcript.txt' -f $LogPath
|
||||
}
|
||||
|
||||
function Get-DTracePath {
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:DTRACE)) {
|
||||
if (Test-Path -LiteralPath $env:DTRACE) {
|
||||
return (Resolve-Path -LiteralPath $env:DTRACE).Path
|
||||
}
|
||||
|
||||
$resolvedOverride = Get-Command -Name $env:DTRACE -ErrorAction SilentlyContinue
|
||||
if ($null -ne $resolvedOverride) {
|
||||
return $resolvedOverride.Source
|
||||
}
|
||||
}
|
||||
|
||||
$dtraceCommand = Get-Command -Name 'dtrace' -ErrorAction SilentlyContinue
|
||||
if ($null -eq $dtraceCommand) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $dtraceCommand.Source
|
||||
}
|
||||
|
||||
function Get-ProfileArgumentList {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Profile
|
||||
)
|
||||
|
||||
switch ($Profile) {
|
||||
'Debug' { return @('--dev') }
|
||||
'Release' { return @('--release') }
|
||||
'ReleaseFast' { return @('--profile', 'release-fast') }
|
||||
'ReleaseSize' { return @('--profile', 'release-size') }
|
||||
default { throw "Unsupported build profile: $Profile" }
|
||||
}
|
||||
}
|
||||
|
||||
function New-FlamegraphArgumentList {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ResolvedBinary,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]$ResolvedTargetArgument
|
||||
)
|
||||
|
||||
$arguments = @('flamegraph') + (Get-ProfileArgumentList -Profile $BuildProfile) + @(
|
||||
'-p',
|
||||
$Package,
|
||||
'--bin',
|
||||
$ResolvedBinary
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Output)) {
|
||||
$arguments += @('-o', $Output)
|
||||
}
|
||||
|
||||
if ($ResolvedTargetArgument.Count -gt 0) {
|
||||
$arguments += '--'
|
||||
$arguments += $ResolvedTargetArgument
|
||||
}
|
||||
|
||||
return $arguments
|
||||
}
|
||||
|
||||
function New-RelayCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ResolvedBinary,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]$ResolvedTargetArgument
|
||||
)
|
||||
|
||||
$relayTokens = @(
|
||||
'&',
|
||||
(Convert-ToSingleQuotedLiteral -Value $PSCommandPath),
|
||||
'-Package',
|
||||
(Convert-ToSingleQuotedLiteral -Value $Package),
|
||||
'-Binary',
|
||||
(Convert-ToSingleQuotedLiteral -Value $ResolvedBinary),
|
||||
'-BuildProfile',
|
||||
(Convert-ToSingleQuotedLiteral -Value $BuildProfile),
|
||||
'-WorkspaceRoot',
|
||||
(Convert-ToSingleQuotedLiteral -Value $WorkspaceRoot),
|
||||
'-NoElevate'
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Output)) {
|
||||
$relayTokens += @('-Output', (Convert-ToSingleQuotedLiteral -Value $Output))
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($LogPath)) {
|
||||
$relayTokens += @('-LogPath', (Convert-ToSingleQuotedLiteral -Value $LogPath))
|
||||
}
|
||||
|
||||
if ($Describe) {
|
||||
$relayTokens += '-Describe'
|
||||
}
|
||||
|
||||
if ($ResolvedTargetArgument.Count -gt 0) {
|
||||
$targetArgumentJson = $ResolvedTargetArgument | ConvertTo-Json -Compress
|
||||
$relayTokens += @('-TargetArgumentJson', (Convert-ToSingleQuotedLiteral -Value $targetArgumentJson))
|
||||
}
|
||||
|
||||
$commandParts = @(
|
||||
"Set-Location -LiteralPath $(Convert-ToSingleQuotedLiteral -Value $WorkspaceRoot)",
|
||||
($relayTokens -join ' ')
|
||||
)
|
||||
|
||||
return ($commandParts -join '; ')
|
||||
}
|
||||
|
||||
$resolvedWorkspaceRoot = [System.IO.Path]::GetFullPath($WorkspaceRoot)
|
||||
$resolvedBinary = if ([string]::IsNullOrWhiteSpace($Binary)) { $Package } else { $Binary }
|
||||
$dtracePath = Get-DTracePath
|
||||
$transcriptPath = Get-TranscriptPath
|
||||
$elevationHostPath = Get-ElevationHostPath
|
||||
$requiresElevation = [string]::IsNullOrWhiteSpace($dtracePath) -and -not (Test-IsAdministrator)
|
||||
$resolvedTargetArgument = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($TargetArgumentJson)) {
|
||||
if ($TargetArgument.Count -gt 0) {
|
||||
throw 'Pass either -TargetArgumentJson or trailing -TargetArgument values, not both.'
|
||||
}
|
||||
|
||||
$decodedArgument = ConvertFrom-Json -InputObject $TargetArgumentJson
|
||||
foreach ($argument in @($decodedArgument)) {
|
||||
$resolvedTargetArgument += [string]$argument
|
||||
}
|
||||
}
|
||||
else {
|
||||
$resolvedTargetArgument = $TargetArgument
|
||||
}
|
||||
|
||||
$flamegraphArguments = New-FlamegraphArgumentList -ResolvedBinary $resolvedBinary -ResolvedTargetArgument $resolvedTargetArgument
|
||||
$relayCommand = New-RelayCommand -ResolvedBinary $resolvedBinary -ResolvedTargetArgument $resolvedTargetArgument
|
||||
|
||||
if ($Describe) {
|
||||
[pscustomobject]@{
|
||||
workspace_root = $resolvedWorkspaceRoot
|
||||
package = $Package
|
||||
binary = $resolvedBinary
|
||||
build_profile = $BuildProfile
|
||||
output = $Output
|
||||
log_path = $LogPath
|
||||
transcript_path = $transcriptPath
|
||||
dtrace_path = $dtracePath
|
||||
elevation_host_path = $elevationHostPath
|
||||
requires_elevation = $requiresElevation
|
||||
target_arguments = $resolvedTargetArgument
|
||||
relay_command = $relayCommand
|
||||
cargo_arguments = $flamegraphArguments
|
||||
} | ConvertTo-Json -Depth 4
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($requiresElevation) {
|
||||
if ($NoElevate) {
|
||||
Write-RelayLog 'Refusing to continue without elevation because no DTrace executable was detected.'
|
||||
Write-Error 'cargo flamegraph on Windows needs an elevated session here because no DTrace executable was detected and blondie would fail with NotAnAdmin.'
|
||||
}
|
||||
|
||||
$encodedRelayCommand = Convert-ToEncodedCommand -Command $relayCommand
|
||||
Write-RelayLog "Requesting elevation with encoded relay command for package '$Package'."
|
||||
try {
|
||||
Start-Process -FilePath $elevationHostPath -Verb RunAs -WorkingDirectory $resolvedWorkspaceRoot -ArgumentList @(
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-EncodedCommand',
|
||||
$encodedRelayCommand
|
||||
) | Out-Null
|
||||
Write-RelayLog 'Elevation request handed off to a new PowerShell window.'
|
||||
Write-Host 'Elevation requested in a new PowerShell window. After UAC approval, cargo flamegraph will run there.'
|
||||
}
|
||||
catch {
|
||||
Write-RelayLog "Elevation request failed: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-RelayLog "Running cargo flamegraph in elevated/session-ready mode from '$resolvedWorkspaceRoot'."
|
||||
$transcriptStarted = $false
|
||||
if (-not [string]::IsNullOrWhiteSpace($LogPath)) {
|
||||
$transcriptPath = Get-TranscriptPath
|
||||
$logDirectory = Split-Path -Parent $transcriptPath
|
||||
if (-not [string]::IsNullOrWhiteSpace($logDirectory)) {
|
||||
New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null
|
||||
}
|
||||
Start-Transcript -LiteralPath $transcriptPath -Append | Out-Null
|
||||
$transcriptStarted = $true
|
||||
}
|
||||
|
||||
Push-Location $resolvedWorkspaceRoot
|
||||
try {
|
||||
Write-RelayLog "Invoking cargo $($flamegraphArguments -join ' ')"
|
||||
cargo @flamegraphArguments
|
||||
Write-RelayLog "cargo flamegraph finished with exit code $LASTEXITCODE"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
if ($transcriptStarted) {
|
||||
Stop-Transcript | Out-Null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$Configuration = 'ReleaseFast',
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$generatorScriptName = 'generate-ai-prompt.ps1'
|
||||
$assetLabel = 'AI prompt'
|
||||
|
||||
function Invoke-GeneratorCheck {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$InvokeArgs
|
||||
)
|
||||
|
||||
try {
|
||||
& $ScriptPath @InvokeArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$generatorScriptName exited with $LASTEXITCODE"
|
||||
}
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Host "$assetLabel check found drift: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-GeneratorWrite {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$InvokeArgs
|
||||
)
|
||||
|
||||
$writeArgs = @{}
|
||||
foreach ($key in $InvokeArgs.Keys) {
|
||||
if ($key -ne 'Check') {
|
||||
$writeArgs[$key] = $InvokeArgs[$key]
|
||||
}
|
||||
}
|
||||
|
||||
& $ScriptPath @writeArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$generatorScriptName exited with $LASTEXITCODE while regenerating $assetLabel"
|
||||
}
|
||||
}
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot $generatorScriptName
|
||||
$invokeArgs = @{
|
||||
Configuration = $Configuration
|
||||
Check = $true
|
||||
}
|
||||
if ($SkipBuild) {
|
||||
$invokeArgs.SkipBuild = $true
|
||||
}
|
||||
|
||||
if (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Regenerating $assetLabel before rechecking."
|
||||
Invoke-GeneratorWrite -ScriptPath $scriptPath -InvokeArgs $invokeArgs
|
||||
|
||||
if (-not (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs)) {
|
||||
throw "Generated $assetLabel is still out of date after regeneration; manual intervention is required."
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$Configuration = 'ReleaseFast',
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$generatorScriptName = 'generate-ai-skill.ps1'
|
||||
$assetLabel = 'Mercury Toolbox skill'
|
||||
|
||||
function Invoke-GeneratorCheck {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$InvokeArgs
|
||||
)
|
||||
|
||||
try {
|
||||
& $ScriptPath @InvokeArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$generatorScriptName exited with $LASTEXITCODE"
|
||||
}
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Host "$assetLabel check found drift: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-GeneratorWrite {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$InvokeArgs
|
||||
)
|
||||
|
||||
$writeArgs = @{}
|
||||
foreach ($key in $InvokeArgs.Keys) {
|
||||
if ($key -ne 'Check') {
|
||||
$writeArgs[$key] = $InvokeArgs[$key]
|
||||
}
|
||||
}
|
||||
|
||||
& $ScriptPath @writeArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$generatorScriptName exited with $LASTEXITCODE while regenerating $assetLabel"
|
||||
}
|
||||
}
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot $generatorScriptName
|
||||
$invokeArgs = @{
|
||||
Configuration = $Configuration
|
||||
Check = $true
|
||||
}
|
||||
if ($SkipBuild) {
|
||||
$invokeArgs.SkipBuild = $true
|
||||
}
|
||||
|
||||
if (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Regenerating $assetLabel before rechecking."
|
||||
Invoke-GeneratorWrite -ScriptPath $scriptPath -InvokeArgs $invokeArgs
|
||||
|
||||
if (-not (Invoke-GeneratorCheck -ScriptPath $scriptPath -InvokeArgs $invokeArgs)) {
|
||||
throw "Generated $assetLabel is still out of date after regeneration; manual intervention is required."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Range = 'HEAD^..HEAD'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$pattern = '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9][a-z0-9._/-]*\))?!?: .+$'
|
||||
$lines = @(& git log --no-merges '--format=%H%x09%s' $Range)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not read commits for range '$Range'."
|
||||
}
|
||||
|
||||
foreach ($line in $lines) {
|
||||
$fields = ([string]$line) -split "`t", 2
|
||||
if ($fields.Count -ne 2 -or -not [regex]::IsMatch($fields[1], $pattern)) {
|
||||
throw "Commit must use Conventional Commits: $line"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Conventional Commit check passed for $($lines.Count) commit(s)."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Remote = 'origin',
|
||||
[string]$BaseUrl,
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
[string]$ApiToken,
|
||||
[string]$Ref = 'main',
|
||||
[string]$WorkflowId = 'ci.yml',
|
||||
[string]$RequiredLabel,
|
||||
[switch]$DispatchIfMissing,
|
||||
[switch]$Wait,
|
||||
[ValidateRange(30, 21600)]
|
||||
[int]$TimeoutSeconds = 1800,
|
||||
[ValidateRange(5, 300)]
|
||||
[int]$PollSeconds = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Invoke-GitCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$output = & git @ArgumentList 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")"
|
||||
}
|
||||
|
||||
return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Resolve-GiteaRepositoryContext {
|
||||
param(
|
||||
[string]$RemoteName,
|
||||
[string]$RemoteUrl
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RemoteUrl)) {
|
||||
$RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', $RemoteName)
|
||||
}
|
||||
|
||||
$normalized = $RemoteUrl.Trim()
|
||||
if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = $Matches[1]
|
||||
Owner = $Matches[2]
|
||||
Repo = $Matches[3]
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
$giteaHost = $Matches[1]
|
||||
$port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" }
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = "https://$giteaHost$port"
|
||||
Owner = $Matches[3]
|
||||
Repo = $Matches[4]
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not parse Gitea remote URL: $normalized"
|
||||
}
|
||||
|
||||
function Resolve-ApiToken {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ResolvedBaseUrl,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepoOwner,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepoName,
|
||||
[string]$ExplicitToken
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) {
|
||||
return $ExplicitToken
|
||||
}
|
||||
|
||||
foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
return $value
|
||||
}
|
||||
}
|
||||
|
||||
$uri = [Uri]$ResolvedBaseUrl
|
||||
$lines = @(
|
||||
'protocol=https'
|
||||
"host=$($uri.Authority)"
|
||||
"path=$RepoOwner/$RepoName.git"
|
||||
''
|
||||
) | git credential fill
|
||||
$passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1
|
||||
if ($null -eq $passwordLine) {
|
||||
throw 'Could not resolve a Gitea API token from the current environment or git credential manager.'
|
||||
}
|
||||
|
||||
return $passwordLine.Substring(9)
|
||||
}
|
||||
|
||||
function Invoke-GiteaApi {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('GET', 'POST')]
|
||||
[string]$Method,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Uri,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Token,
|
||||
[object]$Body
|
||||
)
|
||||
|
||||
$invokeArgs = @{
|
||||
Method = $Method
|
||||
Uri = $Uri
|
||||
Headers = @{ Authorization = "token $Token" }
|
||||
ErrorAction = 'Stop'
|
||||
}
|
||||
if ($PSBoundParameters.ContainsKey('Body')) {
|
||||
$invokeArgs.ContentType = 'application/json'
|
||||
$invokeArgs.Body = $Body | ConvertTo-Json -Depth 8
|
||||
}
|
||||
|
||||
try {
|
||||
return Invoke-RestMethod @invokeArgs
|
||||
}
|
||||
catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($null -eq $response) {
|
||||
throw
|
||||
}
|
||||
|
||||
$statusCode = [int]$response.StatusCode
|
||||
$payload = ''
|
||||
if ($response -is [System.Net.Http.HttpResponseMessage]) {
|
||||
if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) {
|
||||
$payload = $_.ErrorDetails.Message
|
||||
}
|
||||
elseif ($null -ne $response.Content) {
|
||||
try {
|
||||
$payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
catch {
|
||||
$payload = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($null -ne $stream) {
|
||||
$reader = [System.IO.StreamReader]::new($stream)
|
||||
try {
|
||||
$payload = $reader.ReadToEnd()
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OptionalProperty {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$InputObject,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
$property = $InputObject.PSObject.Properties[$Name]
|
||||
if ($null -eq $property) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Resolve-WorkflowRunnerLabel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkflowFileName,
|
||||
[string]$ExplicitLabel
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitLabel)) {
|
||||
return $ExplicitLabel
|
||||
}
|
||||
|
||||
$workflowPath = Join-Path $WorkspaceRoot (Join-Path '.gitea\workflows' $WorkflowFileName)
|
||||
if (-not (Test-Path -LiteralPath $workflowPath -PathType Leaf)) {
|
||||
throw "Workflow file not found: $workflowPath"
|
||||
}
|
||||
|
||||
$content = Get-Content -Raw -LiteralPath $workflowPath
|
||||
$match = [regex]::Match($content, '(?m)^\s*runs-on:\s*[''"]?([^''"\r\n\[\], ]+)')
|
||||
if (-not $match.Success) {
|
||||
throw "Could not resolve runs-on label from $workflowPath"
|
||||
}
|
||||
|
||||
return $match.Groups[1].Value
|
||||
}
|
||||
|
||||
function Find-HeadRun {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Runs,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$HeadSha,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedWorkflowId
|
||||
)
|
||||
|
||||
$matching = @(
|
||||
$Runs.workflow_runs |
|
||||
Where-Object { $_.head_sha -eq $HeadSha } |
|
||||
Where-Object {
|
||||
$workflowId = ''
|
||||
$path = ''
|
||||
if ($_.PSObject.Properties.Name -contains 'workflow_id') {
|
||||
$workflowId = [string]$_.workflow_id
|
||||
}
|
||||
if ($_.PSObject.Properties.Name -contains 'path') {
|
||||
$path = [string]$_.path
|
||||
}
|
||||
|
||||
($workflowId -eq $ExpectedWorkflowId) -or
|
||||
($path -like "$ExpectedWorkflowId@*") -or
|
||||
[string]::IsNullOrWhiteSpace($workflowId)
|
||||
} |
|
||||
Sort-Object id -Descending
|
||||
)
|
||||
|
||||
if ($matching.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $matching[0]
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location -LiteralPath $workspaceRoot
|
||||
|
||||
try {
|
||||
$context = Resolve-GiteaRepositoryContext -RemoteName $Remote
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
$BaseUrl = $context.BaseUrl
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Owner)) {
|
||||
$Owner = $context.Owner
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Repo)) {
|
||||
$Repo = $context.Repo
|
||||
}
|
||||
|
||||
$ApiToken = Resolve-ApiToken -ResolvedBaseUrl $BaseUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken
|
||||
$requiredRunnerLabel = Resolve-WorkflowRunnerLabel -WorkspaceRoot $workspaceRoot -WorkflowFileName $WorkflowId -ExplicitLabel $RequiredLabel
|
||||
$apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo"
|
||||
$workflow = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/workflows/$WorkflowId" -Token $ApiToken
|
||||
if ($workflow.state -ne 'active') {
|
||||
throw "Workflow $WorkflowId is not active; current state is $($workflow.state)."
|
||||
}
|
||||
|
||||
$branch = Invoke-GiteaApi -Method GET -Uri "$apiBase/branches/$Ref" -Token $ApiToken
|
||||
$headSha = [string]$branch.commit.id
|
||||
if ([string]::IsNullOrWhiteSpace($headSha)) {
|
||||
throw "Could not resolve head SHA for ref $Ref."
|
||||
}
|
||||
|
||||
$runners = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runners" -Token $ApiToken
|
||||
$matchingRunners = @(
|
||||
$runners.runners |
|
||||
Where-Object { $_.status -eq 'online' } |
|
||||
Where-Object {
|
||||
$labelNames = @($_.labels | ForEach-Object { $_.name })
|
||||
$labelNames -contains $requiredRunnerLabel
|
||||
}
|
||||
)
|
||||
if ($matchingRunners.Count -eq 0) {
|
||||
throw "No online Gitea runner exposes required label '$requiredRunnerLabel'."
|
||||
}
|
||||
|
||||
$runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken
|
||||
$run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId
|
||||
$dispatched = $false
|
||||
if ($null -eq $run -and $DispatchIfMissing) {
|
||||
Invoke-GiteaApi -Method POST -Uri "$apiBase/actions/workflows/$WorkflowId/dispatches" -Token $ApiToken -Body @{ ref = $Ref } | Out-Null
|
||||
$dispatched = $true
|
||||
}
|
||||
elseif ($null -eq $run) {
|
||||
throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha). Pass -DispatchIfMissing to trigger one."
|
||||
}
|
||||
|
||||
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds)
|
||||
while ($Wait -and ($null -eq $run -or $run.status -ne 'completed')) {
|
||||
if ([DateTimeOffset]::UtcNow -ge $deadline) {
|
||||
$status = if ($null -eq $run) { 'missing' } else { [string]$run.status }
|
||||
throw "Timed out waiting for $WorkflowId at $headSha; last status was $status."
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $PollSeconds
|
||||
$runs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs?limit=30" -Token $ApiToken
|
||||
$run = Find-HeadRun -Runs $runs -HeadSha $headSha -ExpectedWorkflowId $WorkflowId
|
||||
}
|
||||
|
||||
if ($null -eq $run) {
|
||||
throw "No Gitea Actions run found for $WorkflowId at $Ref ($headSha)."
|
||||
}
|
||||
|
||||
$jobs = Invoke-GiteaApi -Method GET -Uri "$apiBase/actions/runs/$($run.id)/jobs" -Token $ApiToken
|
||||
$jobSummaries = @(
|
||||
$jobs.jobs | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
id = $_.id
|
||||
name = $_.name
|
||||
status = $_.status
|
||||
conclusion = Get-OptionalProperty -InputObject $_ -Name 'conclusion'
|
||||
runner_name = Get-OptionalProperty -InputObject $_ -Name 'runner_name'
|
||||
labels = @($_.labels)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$runConclusion = Get-OptionalProperty -InputObject $run -Name 'conclusion'
|
||||
if ($run.status -eq 'completed' -and $runConclusion -ne 'success') {
|
||||
throw "Gitea Actions run $($run.id) completed with conclusion '$runConclusion'."
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ok = ($run.status -eq 'completed' -and $runConclusion -eq 'success')
|
||||
base_url = $BaseUrl
|
||||
owner = $Owner
|
||||
repo = $Repo
|
||||
ref = $Ref
|
||||
head_sha = $headSha
|
||||
workflow_id = $WorkflowId
|
||||
workflow_state = $workflow.state
|
||||
required_runner_label = $requiredRunnerLabel
|
||||
online_runner_count = $matchingRunners.Count
|
||||
dispatched = $dispatched
|
||||
run = [pscustomobject]@{
|
||||
id = $run.id
|
||||
run_number = $run.run_number
|
||||
event = $run.event
|
||||
status = $run.status
|
||||
conclusion = $runConclusion
|
||||
head_sha = $run.head_sha
|
||||
head_branch = $run.head_branch
|
||||
display_title = $run.display_title
|
||||
}
|
||||
jobs = $jobSummaries
|
||||
} | ConvertTo-Json -Depth 8
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$ExemptMiri,
|
||||
[string]$MiriExemptionReason,
|
||||
[switch]$ExemptFuzz,
|
||||
[string]$FuzzExemptionReason,
|
||||
[switch]$ExemptSanitizers,
|
||||
[string]$SanitizersExemptionReason,
|
||||
[switch]$ExemptNoPanic,
|
||||
[string]$NoPanicExemptionReason,
|
||||
[switch]$ExemptLoom,
|
||||
[string]$LoomExemptionReason,
|
||||
[string[]]$MiriPackages = @('common'),
|
||||
[string[]]$SanitizerPackages = @('common'),
|
||||
[ValidateSet('All', 'Miri', 'Fuzz', 'Sanitizers', 'NoPanic', 'Loom')]
|
||||
[string]$Only = 'All',
|
||||
[string]$NightlyToolchain = 'nightly',
|
||||
[ValidateRange(1, 3600)]
|
||||
[int]$FuzzSeconds = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$fuzzManifest = Join-Path $workspaceRoot 'fuzz/Cargo.toml'
|
||||
$fuzzArtifactRoot = Join-Path $workspaceRoot 'target/jade-fuzz/artifacts'
|
||||
Push-Location -LiteralPath $workspaceRoot
|
||||
|
||||
$script:StepResults = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
function Add-StepResult {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[double]$DurationSeconds,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Mode
|
||||
)
|
||||
|
||||
$script:StepResults.Add([pscustomobject]@{
|
||||
Step = $Name
|
||||
Seconds = [math]::Round($DurationSeconds, 2)
|
||||
Mode = $Mode
|
||||
})
|
||||
}
|
||||
|
||||
function Test-NativeCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Resolve-AsanRuntimeDirectory {
|
||||
if (-not $IsWindows) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$targetLibdir = & cargo "+$NightlyToolchain" rustc --print target-libdir 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($targetLibdir)) {
|
||||
foreach ($name in @('clang_rt.asan_dynamic-x86_64.dll', 'libclang_rt.asan_dynamic-x86_64.dll')) {
|
||||
$candidate = Join-Path $targetLibdir $name
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
return (Split-Path -Parent $candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$roots = @(
|
||||
${env:ProgramFiles(x86)},
|
||||
$env:ProgramFiles,
|
||||
(Join-Path $env:USERPROFILE 'scoop'),
|
||||
(Join-Path $env:USERPROFILE '.rustup')
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and (Test-Path -LiteralPath $_) }
|
||||
|
||||
foreach ($root in $roots) {
|
||||
$candidate = Get-ChildItem -LiteralPath $root -Recurse -File -Filter 'clang_rt.asan_dynamic-x86_64.dll' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $candidate) {
|
||||
return $candidate.DirectoryName
|
||||
}
|
||||
|
||||
$mingwCandidate = Get-ChildItem -LiteralPath $root -Recurse -File -Filter 'libclang_rt.asan_dynamic-x86_64.dll' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $mingwCandidate) {
|
||||
return $mingwCandidate.DirectoryName
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Add-AsanRuntimeToPath {
|
||||
if (-not $IsWindows) {
|
||||
return
|
||||
}
|
||||
|
||||
$runtimeDirectory = Resolve-AsanRuntimeDirectory
|
||||
if ([string]::IsNullOrWhiteSpace($runtimeDirectory)) {
|
||||
Deny-MissingGate -Name 'Sanitizers' -Reason 'Windows ASan runtime DLL was not found in the active nightly target-libdir, PATH, or known compiler install roots'
|
||||
}
|
||||
|
||||
$pathParts = $env:PATH -split ';'
|
||||
if ($pathParts -notcontains $runtimeDirectory) {
|
||||
$env:PATH = "${runtimeDirectory};$env:PATH"
|
||||
}
|
||||
}
|
||||
|
||||
function Confirm-Exemption {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool]$Requested,
|
||||
[string]$Reason
|
||||
)
|
||||
|
||||
if (-not $Requested) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Reason)) {
|
||||
throw "${Name} exemption requires a non-empty reason."
|
||||
}
|
||||
|
||||
Write-Warning "${Name} explicitly exempted: ${Reason}"
|
||||
Add-StepResult -Name $Name -DurationSeconds 0 -Mode 'exempted'
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-TimedNative {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $FilePath @ArgumentList
|
||||
$exitCode = $LASTEXITCODE
|
||||
$stopwatch.Stop()
|
||||
Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'hardening'
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-TimedNativeNoStdout {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $FilePath @ArgumentList > $null
|
||||
$exitCode = $LASTEXITCODE
|
||||
$stopwatch.Stop()
|
||||
Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'hardening'
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Deny-MissingGate {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Reason
|
||||
)
|
||||
|
||||
throw "${Name} gate is required by Jade and did not run: ${Reason}. First run 'just jade-tools' or 'pwsh -NoProfile -File .\scripts\install-jade-tooling.ps1' and install the missing platform prerequisite. Use -Exempt${Name} with a reason only for a reviewed, narrow exemption."
|
||||
}
|
||||
|
||||
function Invoke-MiriGate {
|
||||
if (Confirm-Exemption -Name 'Miri' -Requested $ExemptMiri.IsPresent -Reason $MiriExemptionReason) {
|
||||
return
|
||||
}
|
||||
if (-not (Test-NativeCommand -Name 'cargo-miri')) {
|
||||
Deny-MissingGate -Name 'Miri' -Reason 'cargo-miri is not installed for the active toolchain'
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-TimedNative -Name 'cargo miri setup' -FilePath 'cargo' -ArgumentList @("+$NightlyToolchain", 'miri', 'setup')
|
||||
foreach ($package in $MiriPackages) {
|
||||
Invoke-TimedNative -Name "cargo miri ($package)" -FilePath 'cargo' -ArgumentList @(
|
||||
"+$NightlyToolchain",
|
||||
'miri',
|
||||
'test',
|
||||
'-p',
|
||||
$package,
|
||||
'--test',
|
||||
'miri_json_family'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-FuzzGate {
|
||||
if (Confirm-Exemption -Name 'Fuzz' -Requested $ExemptFuzz.IsPresent -Reason $FuzzExemptionReason) {
|
||||
return
|
||||
}
|
||||
if (-not (Test-NativeCommand -Name 'cargo-fuzz')) {
|
||||
Deny-MissingGate -Name 'Fuzz' -Reason 'cargo-fuzz is not installed'
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $fuzzManifest)) {
|
||||
Deny-MissingGate -Name 'Fuzz' -Reason 'fuzz harness manifest is missing'
|
||||
return
|
||||
}
|
||||
Invoke-TimedNativeNoStdout -Name 'cargo fuzz metadata lock' -FilePath 'cargo' -ArgumentList @(
|
||||
"+$NightlyToolchain",
|
||||
'metadata',
|
||||
'--manifest-path',
|
||||
$fuzzManifest,
|
||||
'--locked',
|
||||
'--format-version',
|
||||
'1'
|
||||
)
|
||||
Add-AsanRuntimeToPath
|
||||
New-Item -ItemType Directory -Force -Path $fuzzArtifactRoot | Out-Null
|
||||
|
||||
Invoke-TimedNative -Name 'cargo fuzz json_family_decode' -FilePath 'cargo' -ArgumentList @(
|
||||
"+$NightlyToolchain",
|
||||
'fuzz',
|
||||
'run',
|
||||
'json_family_decode',
|
||||
'--',
|
||||
"-artifact_prefix=$fuzzArtifactRoot/",
|
||||
"-max_total_time=$FuzzSeconds"
|
||||
)
|
||||
}
|
||||
|
||||
function Invoke-SanitizerGate {
|
||||
if (Confirm-Exemption -Name 'Sanitizers' -Requested $ExemptSanitizers.IsPresent -Reason $SanitizersExemptionReason) {
|
||||
return
|
||||
}
|
||||
|
||||
$previousRustFlags = $env:RUSTFLAGS
|
||||
try {
|
||||
Add-AsanRuntimeToPath
|
||||
$env:RUSTFLAGS = '-Zsanitizer=address'
|
||||
foreach ($package in $SanitizerPackages) {
|
||||
Invoke-TimedNative -Name "address sanitizer ($package)" -FilePath 'cargo' -ArgumentList @(
|
||||
"+$NightlyToolchain",
|
||||
'test',
|
||||
'-p',
|
||||
$package,
|
||||
'--test',
|
||||
'miri_json_family'
|
||||
)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$env:RUSTFLAGS = $previousRustFlags
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-NoPanicGate {
|
||||
if (Confirm-Exemption -Name 'NoPanic' -Requested $ExemptNoPanic.IsPresent -Reason $NoPanicExemptionReason) {
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-TimedNative -Name 'no-panic source scan' -FilePath 'pwsh' -ArgumentList @(
|
||||
'-NoProfile',
|
||||
'-File',
|
||||
(Join-Path $PSScriptRoot 'check-no-panic.ps1')
|
||||
)
|
||||
}
|
||||
|
||||
function Invoke-LoomGate {
|
||||
if (Confirm-Exemption -Name 'Loom' -Requested $ExemptLoom.IsPresent -Reason $LoomExemptionReason) {
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-TimedNative -Name 'loom runtime capture model' -FilePath 'cargo' -ArgumentList @(
|
||||
'test',
|
||||
'-p',
|
||||
'runtimekit',
|
||||
'--test',
|
||||
'loom_capture'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($Only) {
|
||||
'All' {
|
||||
Invoke-NoPanicGate
|
||||
Invoke-MiriGate
|
||||
Invoke-LoomGate
|
||||
Invoke-FuzzGate
|
||||
Invoke-SanitizerGate
|
||||
}
|
||||
'Miri' { Invoke-MiriGate }
|
||||
'Fuzz' { Invoke-FuzzGate }
|
||||
'Sanitizers' { Invoke-SanitizerGate }
|
||||
'NoPanic' { Invoke-NoPanicGate }
|
||||
'Loom' { Invoke-LoomGate }
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
$script:StepResults |
|
||||
Sort-Object Step |
|
||||
Format-Table -AutoSize
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$SkipCoverage,
|
||||
[switch]$ExemptMiri,
|
||||
[string]$MiriExemptionReason,
|
||||
[switch]$ExemptFuzz,
|
||||
[string]$FuzzExemptionReason,
|
||||
[switch]$ExemptSanitizers,
|
||||
[string]$SanitizersExemptionReason,
|
||||
[switch]$ExemptNoPanic,
|
||||
[string]$NoPanicExemptionReason,
|
||||
[switch]$ExemptLoom,
|
||||
[string]$LoomExemptionReason,
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$VerificationConfiguration = 'ReleaseFast'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location -LiteralPath $workspaceRoot
|
||||
|
||||
$script:StepResults = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
function Resolve-CoverageTargetRoot {
|
||||
$override = [Environment]::GetEnvironmentVariable('MERCURY_JADE_COVERAGE_ROOT')
|
||||
if (-not [string]::IsNullOrWhiteSpace($override)) {
|
||||
return $override
|
||||
}
|
||||
|
||||
$shortScratch = 'C:\tmp'
|
||||
if (Test-Path -LiteralPath $shortScratch -PathType Container) {
|
||||
return (Join-Path $shortScratch 'mtcov')
|
||||
}
|
||||
|
||||
return (Join-Path ([System.IO.Path]::GetTempPath()) 'mtcov')
|
||||
}
|
||||
|
||||
$coverageTargetRoot = Resolve-CoverageTargetRoot
|
||||
$coverageTargetDir = $null
|
||||
|
||||
function Add-StepResult {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[double]$DurationSeconds,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Mode
|
||||
)
|
||||
|
||||
$script:StepResults.Add([pscustomobject]@{
|
||||
Step = $Name
|
||||
Seconds = [math]::Round($DurationSeconds, 2)
|
||||
Mode = $Mode
|
||||
})
|
||||
}
|
||||
|
||||
function Resolve-BuildArguments {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Configuration
|
||||
)
|
||||
|
||||
$arguments = @('build', '--workspace')
|
||||
switch ($Configuration) {
|
||||
'Release' {
|
||||
$arguments += '--release'
|
||||
}
|
||||
'ReleaseFast' {
|
||||
$arguments += @('--profile', 'release-fast')
|
||||
}
|
||||
'ReleaseSize' {
|
||||
$arguments += @('--profile', 'release-size')
|
||||
}
|
||||
}
|
||||
|
||||
return $arguments
|
||||
}
|
||||
|
||||
function Add-ExemptionArguments {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.Generic.List[string]]$Arguments,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool]$Requested,
|
||||
[string]$Reason
|
||||
)
|
||||
|
||||
if (-not $Requested) {
|
||||
return
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Reason)) {
|
||||
throw "${Name} exemption requires a non-empty reason."
|
||||
}
|
||||
|
||||
$Arguments.Add("-Exempt${Name}")
|
||||
$Arguments.Add("-${Name}ExemptionReason")
|
||||
$Arguments.Add($Reason)
|
||||
}
|
||||
|
||||
function Invoke-TimedNative {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $FilePath @ArgumentList
|
||||
$exitCode = $LASTEXITCODE
|
||||
$stopwatch.Stop()
|
||||
Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'sequential'
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-TimedNativeWithEnvironment {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Environment
|
||||
)
|
||||
|
||||
$previous = @{}
|
||||
foreach ($key in $Environment.Keys) {
|
||||
$item = Get-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue
|
||||
$previous[$key] = [pscustomobject]@{
|
||||
Exists = $null -ne $item
|
||||
Value = if ($null -ne $item) { $item.Value } else { $null }
|
||||
}
|
||||
Set-Item -LiteralPath "Env:$key" -Value ([string]$Environment[$key])
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-TimedNative -Name $Name -FilePath $FilePath -ArgumentList $ArgumentList
|
||||
}
|
||||
finally {
|
||||
foreach ($key in $previous.Keys) {
|
||||
if ($previous[$key].Exists) {
|
||||
Set-Item -LiteralPath "Env:$key" -Value $previous[$key].Value
|
||||
}
|
||||
else {
|
||||
Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-TimedNativeToFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $FilePath @ArgumentList > $OutputPath
|
||||
$exitCode = $LASTEXITCODE
|
||||
$stopwatch.Stop()
|
||||
Add-StepResult -Name $Name -DurationSeconds $stopwatch.Elapsed.TotalSeconds -Mode 'sequential'
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Command failed with exit code ${exitCode}: $FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ParallelNativeSteps {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IEnumerable]$Steps
|
||||
)
|
||||
|
||||
$jobScript = {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$ArgumentList,
|
||||
[int]$Order,
|
||||
[string]$WorkingDirectory
|
||||
)
|
||||
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Set-Location -LiteralPath $WorkingDirectory
|
||||
$outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mercury-jade-" + [System.Guid]::NewGuid() + '.log')
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$exitCode = 0
|
||||
|
||||
try {
|
||||
& $FilePath @ArgumentList *> $outputPath
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
$_ | Out-String | Set-Content -LiteralPath $outputPath -Encoding utf8NoBOM
|
||||
$exitCode = if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 }
|
||||
}
|
||||
finally {
|
||||
$stopwatch.Stop()
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Name = $Name
|
||||
FilePath = $FilePath
|
||||
Arguments = $ArgumentList -join ' '
|
||||
OutputPath = $outputPath
|
||||
ExitCode = [int]$exitCode
|
||||
Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 2)
|
||||
Order = $Order
|
||||
}
|
||||
}
|
||||
|
||||
$jobs = [System.Collections.Generic.List[object]]::new()
|
||||
$index = 0
|
||||
foreach ($step in $Steps) {
|
||||
$jobs.Add((Start-Job -ScriptBlock $jobScript -ArgumentList @(
|
||||
[string]$step.Name,
|
||||
[string]$step.FilePath,
|
||||
[string[]]$step.ArgumentList,
|
||||
$index,
|
||||
$workspaceRoot
|
||||
)))
|
||||
$index += 1
|
||||
}
|
||||
|
||||
Wait-Job -Job $jobs | Out-Null
|
||||
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($job in $jobs) {
|
||||
$result = Receive-Job -Job $job
|
||||
Remove-Job -Job $job -Force
|
||||
|
||||
$output = ''
|
||||
if (Test-Path -LiteralPath $result.OutputPath) {
|
||||
$output = Get-Content -Raw -LiteralPath $result.OutputPath
|
||||
Remove-Item -LiteralPath $result.OutputPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($output)) {
|
||||
Write-Host ''
|
||||
Write-Host "[$($result.Name)]"
|
||||
Write-Host $output.TrimEnd()
|
||||
}
|
||||
|
||||
Add-StepResult -Name $result.Name -DurationSeconds $result.Seconds -Mode 'parallel'
|
||||
if ($result.ExitCode -ne 0) {
|
||||
$failures.Add("$($result.Name) (exit $($result.ExitCode))")
|
||||
}
|
||||
}
|
||||
|
||||
if ($failures.Count -gt 0) {
|
||||
throw "Parallel step(s) failed: $($failures -join ', ')"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-TimedNative -Name 'cargo fmt' -FilePath 'cargo' -ArgumentList @('fmt', '--all', '--check')
|
||||
Invoke-TimedNative -Name 'cargo check' -FilePath 'cargo' -ArgumentList @('check', '--all-targets', '--all-features')
|
||||
|
||||
if ($SkipCoverage) {
|
||||
Invoke-TimedNative -Name 'cargo nextest' -FilePath 'cargo' -ArgumentList @('nextest', 'run', '--all-features')
|
||||
} else {
|
||||
$coverageTargetDir = Join-Path $coverageTargetRoot ("mercury-jade-llvm-cov-" + [System.Guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$coverageEnvironment = @{
|
||||
CARGO_INCREMENTAL = '0'
|
||||
RUSTC_WRAPPER = ''
|
||||
CARGO_TARGET_DIR = $coverageTargetDir
|
||||
}
|
||||
Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov clean' -FilePath 'cargo' -Environment $coverageEnvironment -ArgumentList @('llvm-cov', 'clean', '--workspace')
|
||||
Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov nextest' -FilePath 'cargo' -Environment $coverageEnvironment -ArgumentList @(
|
||||
'llvm-cov',
|
||||
'--jobs',
|
||||
'1',
|
||||
'nextest',
|
||||
'--all-features',
|
||||
'--summary-only',
|
||||
'--ignore-filename-regex',
|
||||
'cli\.rs$|main\.rs$|flow_opcode_table\.rs$',
|
||||
'--fail-under-lines',
|
||||
'80'
|
||||
)
|
||||
}
|
||||
|
||||
Invoke-TimedNative -Name 'cargo clippy' -FilePath 'cargo' -ArgumentList @(
|
||||
'clippy',
|
||||
'--all-targets',
|
||||
'--all-features',
|
||||
'--',
|
||||
'-D',
|
||||
'warnings',
|
||||
'-W',
|
||||
'clippy::pedantic',
|
||||
'-W',
|
||||
'clippy::nursery'
|
||||
)
|
||||
Invoke-TimedNative -Name 'cargo udeps' -FilePath 'cargo' -ArgumentList @('+nightly', 'udeps', '--all-targets', '--all-features')
|
||||
Invoke-TimedNative -Name 'cargo deny' -FilePath 'cargo' -ArgumentList @('deny', 'check')
|
||||
|
||||
$fuzzManifest = Join-Path $workspaceRoot 'fuzz/Cargo.toml'
|
||||
if (Test-Path -LiteralPath $fuzzManifest) {
|
||||
Invoke-TimedNative -Name 'cargo fuzz udeps' -FilePath 'cargo' -ArgumentList @(
|
||||
'+nightly',
|
||||
'udeps',
|
||||
'--manifest-path',
|
||||
$fuzzManifest,
|
||||
'--all-targets',
|
||||
'--all-features'
|
||||
)
|
||||
$fuzzMetadataPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mercury-fuzz-metadata-" + [System.Guid]::NewGuid() + '.json')
|
||||
try {
|
||||
Invoke-TimedNativeToFile -Name 'cargo fuzz metadata' -FilePath 'cargo' -ArgumentList @(
|
||||
'metadata',
|
||||
'--manifest-path',
|
||||
$fuzzManifest,
|
||||
'--locked',
|
||||
'--format-version',
|
||||
'1'
|
||||
) -OutputPath $fuzzMetadataPath
|
||||
Invoke-TimedNative -Name 'cargo fuzz deny' -FilePath 'cargo' -ArgumentList @(
|
||||
'deny',
|
||||
'check',
|
||||
'--config',
|
||||
(Join-Path $workspaceRoot 'deny.toml'),
|
||||
'--metadata-path',
|
||||
$fuzzMetadataPath
|
||||
)
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $fuzzMetadataPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} else {
|
||||
throw "Fuzz workspace manifest is required by Jade and was not found: $fuzzManifest"
|
||||
}
|
||||
|
||||
$jadeHardeningArguments = [System.Collections.Generic.List[string]]::new()
|
||||
$jadeHardeningArguments.Add('-NoProfile')
|
||||
$jadeHardeningArguments.Add('-File')
|
||||
$jadeHardeningArguments.Add((Join-Path $PSScriptRoot 'check-jade-hardening.ps1'))
|
||||
Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Miri' -Requested $ExemptMiri.IsPresent -Reason $MiriExemptionReason
|
||||
Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Fuzz' -Requested $ExemptFuzz.IsPresent -Reason $FuzzExemptionReason
|
||||
Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Sanitizers' -Requested $ExemptSanitizers.IsPresent -Reason $SanitizersExemptionReason
|
||||
Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'NoPanic' -Requested $ExemptNoPanic.IsPresent -Reason $NoPanicExemptionReason
|
||||
Add-ExemptionArguments -Arguments $jadeHardeningArguments -Name 'Loom' -Requested $ExemptLoom.IsPresent -Reason $LoomExemptionReason
|
||||
Invoke-TimedNative -Name 'jade hardening' -FilePath 'pwsh' -ArgumentList $jadeHardeningArguments.ToArray()
|
||||
|
||||
Invoke-TimedNative -Name "cargo build ($VerificationConfiguration)" -FilePath 'cargo' -ArgumentList (Resolve-BuildArguments -Configuration $VerificationConfiguration)
|
||||
|
||||
Invoke-ParallelNativeSteps -Steps @(
|
||||
@{
|
||||
Name = 'check-powershell'
|
||||
FilePath = 'pwsh'
|
||||
ArgumentList = @('-NoProfile', '-File', (Join-Path $PSScriptRoot 'check-powershell.ps1'))
|
||||
},
|
||||
@{
|
||||
Name = 'check-ai-prompt'
|
||||
FilePath = 'pwsh'
|
||||
ArgumentList = @(
|
||||
'-NoProfile',
|
||||
'-File',
|
||||
(Join-Path $PSScriptRoot 'check-ai-prompt.ps1'),
|
||||
'-Configuration',
|
||||
$VerificationConfiguration,
|
||||
'-SkipBuild'
|
||||
)
|
||||
},
|
||||
@{
|
||||
Name = 'check-ai-skill'
|
||||
FilePath = 'pwsh'
|
||||
ArgumentList = @(
|
||||
'-NoProfile',
|
||||
'-File',
|
||||
(Join-Path $PSScriptRoot 'check-ai-skill.ps1'),
|
||||
'-Configuration',
|
||||
$VerificationConfiguration,
|
||||
'-SkipBuild'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Invoke-TimedNative -Name 'check-ecosystem' -FilePath 'pwsh' -ArgumentList @(
|
||||
'-NoProfile',
|
||||
'-File',
|
||||
(Join-Path $PSScriptRoot 'check-ecosystem.ps1'),
|
||||
'-Configuration',
|
||||
$VerificationConfiguration,
|
||||
'-SkipBuild',
|
||||
'-SkipPromptGeneration'
|
||||
)
|
||||
|
||||
Write-Host ''
|
||||
$script:StepResults |
|
||||
Sort-Object Step |
|
||||
Format-Table -AutoSize
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $coverageTargetDir -and (Test-Path -LiteralPath $coverageTargetDir)) {
|
||||
$resolvedRoot = [System.IO.Path]::GetFullPath($coverageTargetRoot)
|
||||
$resolvedTarget = [System.IO.Path]::GetFullPath($coverageTargetDir)
|
||||
$rootPrefix = $resolvedRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
||||
if ($resolvedTarget.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Remove-Item -LiteralPath $coverageTargetDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string[]]$Path = @(
|
||||
'crates/common/src/formats',
|
||||
'crates/toon/src',
|
||||
'crates/ison/src',
|
||||
'crates/isonl/src',
|
||||
'crates/zon/src',
|
||||
'crates/tonl/src'
|
||||
)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location -LiteralPath $workspaceRoot
|
||||
|
||||
function Get-BraceDelta {
|
||||
param(
|
||||
[AllowEmptyString()]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Line
|
||||
)
|
||||
|
||||
$open = ([regex]::Matches($Line, '\{')).Count
|
||||
$close = ([regex]::Matches($Line, '\}')).Count
|
||||
return $open - $close
|
||||
}
|
||||
|
||||
function Test-AllowedLine {
|
||||
param(
|
||||
[AllowEmptyString()]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Line,
|
||||
[AllowEmptyString()]
|
||||
[string]$PreviousLine
|
||||
)
|
||||
|
||||
$sameLineComment = $Line -match '(^|\s)//\s*jade:\s*allow-panic\s+because:\s+\S'
|
||||
$previousLineComment = -not [string]::IsNullOrWhiteSpace($PreviousLine) -and
|
||||
$PreviousLine -match '^\s*//\s*jade:\s*allow-panic\s+because:\s+\S'
|
||||
|
||||
return $sameLineComment -or (
|
||||
-not [string]::IsNullOrWhiteSpace($PreviousLine) -and
|
||||
$previousLineComment
|
||||
)
|
||||
}
|
||||
|
||||
$patterns = @(
|
||||
'panic!\s*\(',
|
||||
'\.unwrap\s*\(',
|
||||
'\.expect\s*\('
|
||||
)
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
try {
|
||||
foreach ($root in $Path) {
|
||||
if (-not (Test-Path -LiteralPath $root)) {
|
||||
throw "No-panic scan path does not exist: $root"
|
||||
}
|
||||
|
||||
$files = Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.rs'
|
||||
foreach ($file in $files) {
|
||||
if (($file.FullName -split '[\\/]') -contains 'tests') {
|
||||
continue
|
||||
}
|
||||
|
||||
$lines = Get-Content -LiteralPath $file.FullName
|
||||
$pendingTestModule = $false
|
||||
$insideTestModule = $false
|
||||
$braceDepth = 0
|
||||
$previousLine = ''
|
||||
for ($index = 0; $index -lt $lines.Count; $index += 1) {
|
||||
$line = [string]$lines[$index]
|
||||
$trimmed = $line.TrimStart()
|
||||
|
||||
if ($insideTestModule) {
|
||||
$braceDepth += Get-BraceDelta -Line $line
|
||||
if ($braceDepth -le 0) {
|
||||
$insideTestModule = $false
|
||||
$braceDepth = 0
|
||||
}
|
||||
$previousLine = $line
|
||||
continue
|
||||
}
|
||||
|
||||
if ($trimmed.StartsWith('#[cfg(test)]')) {
|
||||
$pendingTestModule = $true
|
||||
$previousLine = $line
|
||||
continue
|
||||
}
|
||||
|
||||
if ($pendingTestModule -and $trimmed -match '^mod\s+tests\s*\{') {
|
||||
$insideTestModule = $true
|
||||
$braceDepth = Get-BraceDelta -Line $line
|
||||
$pendingTestModule = $false
|
||||
$previousLine = $line
|
||||
continue
|
||||
}
|
||||
if (-not $trimmed.StartsWith('#[')) {
|
||||
$pendingTestModule = $false
|
||||
}
|
||||
|
||||
if ($trimmed.StartsWith('//')) {
|
||||
$previousLine = $line
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($pattern in $patterns) {
|
||||
if ($line -match $pattern -and -not (Test-AllowedLine -Line $line -PreviousLine $previousLine)) {
|
||||
$relative = Resolve-Path -LiteralPath $file.FullName -Relative
|
||||
$failures.Add("${relative}:$($index + 1): panic surface requires removal, fallible propagation, or a strict '// jade: allow-panic because: <reason>' comment directly beside this call")
|
||||
}
|
||||
}
|
||||
|
||||
$previousLine = $line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Error $_ }
|
||||
throw "No-panic gate found $($failures.Count) unapproved panic surface(s)."
|
||||
}
|
||||
|
||||
Write-Host "No-panic gate passed for $($Path.Count) root(s)."
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Assert-Condition {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool]$Condition,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
if (-not $Condition) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
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 Test-RequiredHeaderLine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedLine
|
||||
)
|
||||
|
||||
$normalizedLines = @(
|
||||
$Content -split "\r?\n" |
|
||||
ForEach-Object { $_.Trim() } |
|
||||
Where-Object { $_ -ne '' }
|
||||
)
|
||||
|
||||
return $ExpectedLine -in $normalizedLines
|
||||
}
|
||||
|
||||
function Assert-ScriptRegex {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.IO.FileInfo]$ScriptFile,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Pattern,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
if (-not [regex]::IsMatch($Content, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) {
|
||||
throw "$Message`: $($ScriptFile.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$settingsPath = Join-Path $workspaceRoot 'PSScriptAnalyzerSettings.psd1'
|
||||
$scriptFiles = @(Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1' -File | Sort-Object Name)
|
||||
$requiredHeaderLines = @(
|
||||
"`$ErrorActionPreference = 'Stop'",
|
||||
'Set-StrictMode -Version Latest'
|
||||
)
|
||||
$scriptContentByName = @{}
|
||||
|
||||
Assert-Condition (Test-Path -LiteralPath $settingsPath) "Missing ScriptAnalyzer settings file: $settingsPath"
|
||||
[void](Get-RequiredCommandPath -Name 'Invoke-ScriptAnalyzer')
|
||||
|
||||
foreach ($scriptFile in $scriptFiles) {
|
||||
$scriptContentByName[$scriptFile.Name] = @{
|
||||
File = $scriptFile
|
||||
Content = Get-Content -Raw -LiteralPath $scriptFile.FullName
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($scriptFile in $scriptFiles) {
|
||||
if ($scriptFile.Name -eq 'toolbox-commands.ps1') {
|
||||
continue
|
||||
}
|
||||
|
||||
$content = $scriptContentByName[$scriptFile.Name].Content
|
||||
foreach ($requiredLine in $requiredHeaderLines) {
|
||||
Assert-Condition (
|
||||
Test-RequiredHeaderLine -Content $content -ExpectedLine $requiredLine
|
||||
) "PowerShell script must include required header line '$requiredLine': $($scriptFile.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
$validationMatrix = @(
|
||||
@{
|
||||
Script = 'install-toolbox.ps1'
|
||||
Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot'
|
||||
Message = 'Installer must reject an empty InstallRoot argument during parameter binding'
|
||||
},
|
||||
@{
|
||||
Script = 'install-package-toolbox.ps1'
|
||||
Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot'
|
||||
Message = 'Package installer must reject an empty InstallRoot argument during parameter binding'
|
||||
},
|
||||
@{
|
||||
Script = 'uninstall-toolbox.ps1'
|
||||
Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot'
|
||||
Message = 'Uninstaller must reject an empty InstallRoot argument during parameter binding'
|
||||
},
|
||||
@{
|
||||
Script = 'uninstall-package-toolbox.ps1'
|
||||
Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$InstallRoot'
|
||||
Message = 'Package uninstaller must reject an empty InstallRoot argument during parameter binding'
|
||||
},
|
||||
@{
|
||||
Script = 'package-toolbox.ps1'
|
||||
Pattern = 'param\([\s\S]*?\[ValidateNotNullOrEmpty\(\)\]\s*\[string\]\$OutputRoot'
|
||||
Message = 'Packager must reject an empty OutputRoot argument during parameter binding'
|
||||
},
|
||||
@{
|
||||
Script = 'package-toolbox.ps1'
|
||||
Pattern = 'Assert-SingleDirectoryName[\s\S]*IsPathRooted[\s\S]*GetFileName[\s\S]*''\.\'', ''\.\.'''
|
||||
Message = 'Packager must reject PackageName values that are rooted paths, nested paths, dot, or dot-dot'
|
||||
},
|
||||
@{
|
||||
Script = 'package-toolbox.ps1'
|
||||
Pattern = 'Resolved package root must stay inside OutputRoot'
|
||||
Message = 'Packager must reject resolved package roots that escape OutputRoot'
|
||||
},
|
||||
@{
|
||||
Script = 'package-toolbox.ps1'
|
||||
Pattern = 'OutputRoot must not be a reparse point'
|
||||
Message = 'Packager must reject reparse-point OutputRoot values before writing output'
|
||||
},
|
||||
@{
|
||||
Script = 'package-toolbox.ps1'
|
||||
Pattern = 'Get-RequiredCommandPath -Name ''rustc''[\s\S]*Failed to query rustc host target with command'
|
||||
Message = 'Packager must resolve rustc explicitly and report the failing rustc probe command'
|
||||
},
|
||||
@{
|
||||
Script = 'package-toolbox.ps1'
|
||||
Pattern = 'Assert-ArchiveCreated[\s\S]*Compress-Archive did not create the expected archive[\s\S]*Compress-Archive created an empty archive'
|
||||
Message = 'Packager must verify that Compress-Archive produced a non-empty archive'
|
||||
},
|
||||
@{
|
||||
Script = 'install-toolbox.ps1'
|
||||
Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot'
|
||||
Message = 'Installer must reject reparse-point InstallRoot values before mutation'
|
||||
},
|
||||
@{
|
||||
Script = 'install-package-toolbox.ps1'
|
||||
Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot'
|
||||
Message = 'Package installer must reject reparse-point InstallRoot values before mutation'
|
||||
},
|
||||
@{
|
||||
Script = 'uninstall-toolbox.ps1'
|
||||
Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot'
|
||||
Message = 'Uninstaller must reject reparse-point InstallRoot values before removal'
|
||||
},
|
||||
@{
|
||||
Script = 'uninstall-package-toolbox.ps1'
|
||||
Pattern = 'Assert-ExistingPathNotReparsePoint -Path \$InstallRoot'
|
||||
Message = 'Package uninstaller must reject reparse-point InstallRoot values before removal'
|
||||
},
|
||||
@{
|
||||
Script = 'install-package-toolbox.ps1'
|
||||
Pattern = 'Checksum path escapes package root'
|
||||
Message = 'Package installer must reject checksum entries that escape the package root'
|
||||
},
|
||||
@{
|
||||
Script = 'install-package-toolbox.ps1'
|
||||
Pattern = 'Duplicate checksum entry for package file'
|
||||
Message = 'Package installer must reject duplicate checksum entries'
|
||||
},
|
||||
@{
|
||||
Script = 'install-package-toolbox.ps1'
|
||||
Pattern = 'Package contains file missing from SHA256SUMS'
|
||||
Message = 'Package installer must reject package files missing from SHA256SUMS'
|
||||
},
|
||||
@{
|
||||
Script = 'install-toolbox.ps1'
|
||||
Pattern = 'Failed to create current install junction[\s\S]*NTFS-compatible install root[\s\S]*Current install junction was not created'
|
||||
Message = 'Installer must explain Windows junction creation failures and verify the current link exists'
|
||||
},
|
||||
@{
|
||||
Script = 'install-package-toolbox.ps1'
|
||||
Pattern = 'Failed to create current install junction[\s\S]*NTFS-compatible install root[\s\S]*Current install junction was not created'
|
||||
Message = 'Package installer must explain Windows junction creation failures and verify the current link exists'
|
||||
},
|
||||
@{
|
||||
Script = 'toolbox-commands.ps1'
|
||||
Pattern = 'Format-ToolboxNativeCommand[\s\S]*Command failed with exit code[\s\S]*Get-Location'
|
||||
Message = 'Shared native command runner must include a quoted command line and cwd in failures'
|
||||
},
|
||||
@{
|
||||
Script = 'setup-gitea-runner.ps1'
|
||||
Pattern = 'HttpResponseMessage[\s\S]*ErrorDetails[\s\S]*ReadAsStringAsync[\s\S]*GetResponseStream'
|
||||
Message = 'Gitea runner setup must handle PowerShell 7 and legacy HTTP error responses'
|
||||
},
|
||||
@{
|
||||
Script = 'publish-gitea-release.ps1'
|
||||
Pattern = 'HttpResponseMessage[\s\S]*ErrorDetails[\s\S]*ReadAsStringAsync[\s\S]*GetResponseStream'
|
||||
Message = 'Gitea release publisher must handle PowerShell 7 and legacy HTTP error responses'
|
||||
},
|
||||
@{
|
||||
Script = 'invoke-gitea-git.ps1'
|
||||
Pattern = 'credential\.helper=[\s\S]*http\.sslBackend=openssl[\s\S]*http\.extraHeader=Authorization: Basic'
|
||||
Message = 'Gitea Git helper must use transient Basic auth while disabling credential-manager lookup'
|
||||
},
|
||||
@{
|
||||
Script = 'invoke-gitea-git.ps1'
|
||||
Pattern = 'GIT_TERMINAL_PROMPT[\s\S]*GIT_TRACE_CURL[\s\S]*GIT_CURL_VERBOSE'
|
||||
Message = 'Gitea Git helper must disable prompts and curl tracing while the transient header is in scope'
|
||||
},
|
||||
@{
|
||||
Script = 'invoke-gitea-git.ps1'
|
||||
Pattern = 'Write-GitOutput[\s\S]*http\\\.c:\\d\+[\s\S]*2>&1'
|
||||
Message = 'Gitea Git helper must filter libcurl trace output from captured git stderr'
|
||||
},
|
||||
@{
|
||||
Script = 'invoke-gitea-git.ps1'
|
||||
Pattern = 'InsecureSkipTlsVerify[\s\S]*http\.sslVerify=false'
|
||||
Message = 'Gitea Git helper may skip TLS verification only through an explicit opt-in switch'
|
||||
},
|
||||
@{
|
||||
Script = 'invoke-gitea-git.ps1'
|
||||
Pattern = 'deliberately does not call Git Credential Manager'
|
||||
Message = 'Gitea Git helper must fail closed instead of falling back to Git Credential Manager'
|
||||
},
|
||||
@{
|
||||
Script = 'check-gitea-ci.ps1'
|
||||
Pattern = 'actions/workflows/\$WorkflowId[\s\S]*actions/runners[\s\S]*actions/runs'
|
||||
Message = 'Gitea CI checker must inspect workflow, runner, and run state through the Gitea API'
|
||||
},
|
||||
@{
|
||||
Script = 'check-gitea-ci.ps1'
|
||||
Pattern = 'DispatchIfMissing[\s\S]*dispatches[\s\S]*Wait'
|
||||
Message = 'Gitea CI checker must support dispatch fallback and wait for a conclusive run'
|
||||
},
|
||||
@{
|
||||
Script = 'check-ai-prompt.ps1'
|
||||
Pattern = 'Invoke-GeneratorCheck[\s\S]*Invoke-GeneratorWrite[\s\S]*Invoke-GeneratorCheck'
|
||||
Message = 'AI prompt checker must regenerate once and recheck before failing generated asset drift'
|
||||
},
|
||||
@{
|
||||
Script = 'check-ai-skill.ps1'
|
||||
Pattern = 'Invoke-GeneratorCheck[\s\S]*Invoke-GeneratorWrite[\s\S]*Invoke-GeneratorCheck'
|
||||
Message = 'AI skill checker must regenerate once and recheck before failing generated asset drift'
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($validationCase in $validationMatrix) {
|
||||
Assert-Condition ($scriptContentByName.ContainsKey($validationCase.Script)) "Validation matrix references missing script: $($validationCase.Script)"
|
||||
$scriptRecord = $scriptContentByName[$validationCase.Script]
|
||||
Assert-ScriptRegex -ScriptFile $scriptRecord.File -Content $scriptRecord.Content -Pattern $validationCase.Pattern -Message $validationCase.Message
|
||||
}
|
||||
|
||||
$diagnostics = @(Invoke-ScriptAnalyzer -Path $PSScriptRoot -Recurse -Settings $settingsPath)
|
||||
if ($diagnostics.Count -gt 0) {
|
||||
$diagnostics |
|
||||
Select-Object RuleName, Severity, ScriptName, Line, Message |
|
||||
Format-Table -AutoSize |
|
||||
Out-String |
|
||||
Write-Output
|
||||
throw "PSScriptAnalyzer reported $($diagnostics.Count) diagnostic(s)."
|
||||
}
|
||||
|
||||
Write-Host "PowerShell gate passed for $($scriptFiles.Count) script file(s)"
|
||||
@@ -0,0 +1,47 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$Tag
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Test-SemVer {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
$match = [regex]::Match(
|
||||
$Version,
|
||||
'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$'
|
||||
)
|
||||
if (-not $match.Success) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$preRelease = $match.Groups[4].Value
|
||||
return @($preRelease -split '\.' | Where-Object {
|
||||
$_ -match '^0\d+$'
|
||||
}).Count -eq 0
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$cargoToml = Get-Content -Raw -LiteralPath (Join-Path $workspaceRoot 'Cargo.toml')
|
||||
$match = [regex]::Match($cargoToml, '(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"')
|
||||
if (-not $match.Success) {
|
||||
throw 'Could not resolve [workspace.package].version from Cargo.toml.'
|
||||
}
|
||||
|
||||
$version = $match.Groups[1].Value
|
||||
if (-not (Test-SemVer -Version $version)) {
|
||||
throw "Workspace version is not valid SemVer: $version"
|
||||
}
|
||||
|
||||
if ($Tag -ne "v$version") {
|
||||
throw "Tag $Tag does not match workspace version v$version."
|
||||
}
|
||||
|
||||
Write-Host "SemVer release tag verified: $Tag"
|
||||
@@ -0,0 +1,341 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$Configuration = 'ReleaseFast',
|
||||
[string]$OutputPath = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs') 'ai\mercury-toolbox-ai-prompt.md'),
|
||||
[string]$NotesPath = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs') 'ai\toolbox-ai-prompt-notes.json'),
|
||||
[switch]$Check,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$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-ToolHelpSections {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$HelpText
|
||||
)
|
||||
|
||||
$lines = $HelpText -split "`r?`n"
|
||||
$summary = ($lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim()
|
||||
$usage = [System.Collections.Generic.List[string]]::new()
|
||||
$examples = [System.Collections.Generic.List[string]]::new()
|
||||
$section = ''
|
||||
|
||||
foreach ($line in $lines) {
|
||||
$trimmed = $line.Trim()
|
||||
if ($trimmed -ceq 'Usage:') {
|
||||
$section = 'usage'
|
||||
continue
|
||||
}
|
||||
|
||||
if ($trimmed -ceq 'Examples:') {
|
||||
$section = 'examples'
|
||||
continue
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-HelpSectionHeader -Line $trimmed) {
|
||||
$section = ''
|
||||
continue
|
||||
}
|
||||
|
||||
switch ($section) {
|
||||
'usage' { $usage.Add($trimmed) }
|
||||
'examples' { $examples.Add($trimmed) }
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Summary = $summary
|
||||
Usage = @($usage)
|
||||
Examples = @($examples)
|
||||
}
|
||||
}
|
||||
|
||||
function Test-HelpSectionHeader {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Line
|
||||
)
|
||||
|
||||
return $Line -cmatch '^[A-Z][A-Za-z0-9 /_-]*:$'
|
||||
}
|
||||
|
||||
function Append-Section {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Text.StringBuilder]$Builder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Heading,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Items
|
||||
)
|
||||
|
||||
[void]$Builder.AppendLine("## $Heading")
|
||||
[void]$Builder.AppendLine()
|
||||
foreach ($item in $Items) {
|
||||
[void]$Builder.AppendLine("- $item")
|
||||
}
|
||||
[void]$Builder.AppendLine()
|
||||
}
|
||||
|
||||
function Select-PromptExamples {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Examples,
|
||||
[int]$Limit = 1
|
||||
)
|
||||
|
||||
$selected = [System.Collections.Generic.List[string]]::new()
|
||||
if ($Examples.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
|
||||
foreach ($example in $Examples) {
|
||||
if (-not $example.Contains('Get-Content') -and -not $example.Contains('fixtures')) {
|
||||
$selected.Add($example)
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($selected.Count -eq 0) {
|
||||
foreach ($example in $Examples) {
|
||||
if (-not $example.Contains('Get-Content')) {
|
||||
$selected.Add($example)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($selected.Count -eq 0) {
|
||||
$selected.Add($Examples[0])
|
||||
}
|
||||
|
||||
foreach ($example in $Examples | Select-Object -Skip 1) {
|
||||
if (
|
||||
$selected.Count -lt $Limit -and
|
||||
$example -cne $selected[0] -and
|
||||
-not $example.Contains('Get-Content') -and
|
||||
-not $example.Contains('fixtures') -and
|
||||
($example.Contains('|') -or $example.Contains('ConvertFrom-Json'))
|
||||
) {
|
||||
$selected.Add($example)
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($example in $Examples | Select-Object -Skip 1) {
|
||||
if ($selected.Count -ge $Limit) {
|
||||
break
|
||||
}
|
||||
|
||||
if (
|
||||
-not $selected.Contains($example) -and
|
||||
-not $example.Contains('Get-Content') -and
|
||||
-not $example.Contains('fixtures')
|
||||
) {
|
||||
$selected.Add($example)
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($example in $Examples | Select-Object -Skip 1) {
|
||||
if ($selected.Count -ge $Limit) {
|
||||
break
|
||||
}
|
||||
|
||||
if (-not $selected.Contains($example)) {
|
||||
$selected.Add($example)
|
||||
}
|
||||
}
|
||||
|
||||
return @($selected)
|
||||
}
|
||||
|
||||
function Normalize-GeneratedExample {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Example
|
||||
)
|
||||
|
||||
return ($Example -replace '(?i)(?:[A-Z]:)?[^''"\s|]*fixtures(?:[\\/][^''"\s|]+)+', '<PATH>')
|
||||
}
|
||||
|
||||
function Test-ByteSequenceEqual {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[byte[]]$Left,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[byte[]]$Right
|
||||
)
|
||||
|
||||
if ($Left.Length -ne $Right.Length) {
|
||||
return $false
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $Left.Length; $index += 1) {
|
||||
if ($Left[$index] -ne $Right[$index]) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function ConvertTo-LfText {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content
|
||||
)
|
||||
|
||||
return $Content.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
}
|
||||
|
||||
function Write-Utf8FileWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content,
|
||||
[int]$Attempts = 20,
|
||||
[int]$DelayMilliseconds = 100
|
||||
)
|
||||
|
||||
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||
try {
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($Path, (ConvertTo-LfText -Content $Content), $encoding)
|
||||
return
|
||||
}
|
||||
catch {
|
||||
if ($attempt -eq $Attempts) {
|
||||
throw
|
||||
}
|
||||
Start-Sleep -Milliseconds $DelayMilliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Select-PromptUsage {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$ToolNotes,
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Help,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CommandName
|
||||
)
|
||||
|
||||
if ($ToolNotes.ContainsKey('prompt_usage') -and -not [string]::IsNullOrWhiteSpace([string]$ToolNotes.prompt_usage)) {
|
||||
return [string]$ToolNotes.prompt_usage
|
||||
}
|
||||
if ($Help.Usage.Count -gt 0) {
|
||||
return (([string[]]$Help.Usage) -join ' | ')
|
||||
}
|
||||
return "$CommandName --help"
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$profileName = Resolve-ToolboxProfileName -Configuration $Configuration
|
||||
$binaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $profileName)
|
||||
$dependencyRoot = Join-Path $binaryRoot 'deps'
|
||||
if (Test-Path -LiteralPath $dependencyRoot -PathType Container) {
|
||||
$env:PATH = "$dependencyRoot$([System.IO.Path]::PathSeparator)$env:PATH"
|
||||
}
|
||||
$cargoPath = 'cargo'
|
||||
$notes = Get-Content -Raw -LiteralPath $NotesPath | ConvertFrom-Json -AsHashtable
|
||||
|
||||
$missingBinary = $false
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $binaryRoot "$commandName.exe"))) {
|
||||
$missingBinary = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingBinary) {
|
||||
if ($SkipBuild) {
|
||||
throw "Required toolbox binaries are missing under $binaryRoot. Build profile $Configuration before running generate-ai-prompt.ps1 -SkipBuild."
|
||||
}
|
||||
Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration
|
||||
}
|
||||
|
||||
$builder = [System.Text.StringBuilder]::new()
|
||||
[void]$builder.AppendLine("# $($notes.title)")
|
||||
[void]$builder.AppendLine(('Available tools: {0}' -f ((Get-ToolboxCommandNames) -join ', ')))
|
||||
[void]$builder.AppendLine(('Rules: {0}' -f (([string[]]$notes.selection_rules) -join ' ')))
|
||||
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
$binaryPath = Join-Path $binaryRoot "$commandName.exe"
|
||||
$toolNotes = $notes.tools[$commandName]
|
||||
if ($null -eq $toolNotes) {
|
||||
throw "Missing AI prompt notes for tool '$commandName'"
|
||||
}
|
||||
|
||||
$helpText = (& $binaryPath '--help' | Out-String).TrimEnd()
|
||||
$help = Get-ToolHelpSections -HelpText $helpText
|
||||
$usageLine = Select-PromptUsage -ToolNotes $toolNotes -Help $help -CommandName $commandName
|
||||
$examples = @(Select-PromptExamples -Examples ([string[]]$help.Examples))
|
||||
$exampleLine = if ($toolNotes.ContainsKey('prompt_example') -and -not [string]::IsNullOrWhiteSpace([string]$toolNotes.prompt_example)) {
|
||||
[string]$toolNotes.prompt_example
|
||||
} elseif ($examples.Count -gt 0) {
|
||||
$examples[0]
|
||||
} else {
|
||||
"$commandName --help"
|
||||
}
|
||||
$exampleLine = Normalize-GeneratedExample -Example $exampleLine
|
||||
$guided = $toolNotes.guided_triage
|
||||
if ($null -eq $guided) {
|
||||
throw "Missing guided_triage notes for tool '$commandName'"
|
||||
}
|
||||
$nextActions = ([string[]]$guided.next_actions) -join ' | '
|
||||
|
||||
[void]$builder.AppendLine(('{0}: {1} Better than: {2} Usage: `{3}` Example: `{4}` Guided: answer={5} trust={6} next={7}' -f $commandName, $toolNotes.use_when, $toolNotes.why, $usageLine, $exampleLine, $guided.answer, $guided.trust, $nextActions))
|
||||
}
|
||||
|
||||
$content = $builder.ToString()
|
||||
|
||||
if ($Check) {
|
||||
if (-not (Test-Path -LiteralPath $OutputPath)) {
|
||||
throw "Generated AI prompt is missing: $OutputPath"
|
||||
}
|
||||
|
||||
$temporaryOutputPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
|
||||
try {
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($temporaryOutputPath, (ConvertTo-LfText -Content $content), $encoding)
|
||||
$existingBytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $OutputPath))
|
||||
$generatedBytes = [System.IO.File]::ReadAllBytes($temporaryOutputPath)
|
||||
if (-not (Test-ByteSequenceEqual -Left $existingBytes -Right $generatedBytes)) {
|
||||
throw "Generated AI prompt is out of date. Run pwsh -NoProfile -File .\scripts\generate-ai-prompt.ps1"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $temporaryOutputPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "AI prompt is up to date: $OutputPath"
|
||||
return
|
||||
}
|
||||
|
||||
$outputDirectory = Split-Path -Parent $OutputPath
|
||||
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||
Write-Utf8FileWithRetry -Path $OutputPath -Content $content
|
||||
Write-Host "Generated AI prompt: $OutputPath"
|
||||
@@ -0,0 +1,468 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$Configuration = 'ReleaseFast',
|
||||
[string]$SkillRoot = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'skills') 'mercury-toolbox'),
|
||||
[string]$NotesPath = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'docs') 'ai\toolbox-ai-prompt-notes.json'),
|
||||
[switch]$Check,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$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-ToolHelpSections {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$HelpText
|
||||
)
|
||||
|
||||
$lines = $HelpText -split "`r?`n"
|
||||
$summary = ($lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim()
|
||||
$usage = [System.Collections.Generic.List[string]]::new()
|
||||
$examples = [System.Collections.Generic.List[string]]::new()
|
||||
$section = ''
|
||||
|
||||
foreach ($line in $lines) {
|
||||
$trimmed = $line.Trim()
|
||||
if ($trimmed -ceq 'Usage:') {
|
||||
$section = 'usage'
|
||||
continue
|
||||
}
|
||||
|
||||
if ($trimmed -ceq 'Examples:') {
|
||||
$section = 'examples'
|
||||
continue
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-HelpSectionHeader -Line $trimmed) {
|
||||
$section = ''
|
||||
continue
|
||||
}
|
||||
|
||||
switch ($section) {
|
||||
'usage' { $usage.Add($trimmed) }
|
||||
'examples' { $examples.Add($trimmed) }
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Summary = $summary
|
||||
Usage = @($usage)
|
||||
Examples = @($examples)
|
||||
}
|
||||
}
|
||||
|
||||
function Test-HelpSectionHeader {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Line
|
||||
)
|
||||
|
||||
return $Line -cmatch '^[A-Z][A-Za-z0-9 /_-]*:$'
|
||||
}
|
||||
|
||||
function Select-CatalogExample {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Examples
|
||||
)
|
||||
|
||||
if ($Examples.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($example in $Examples) {
|
||||
if (
|
||||
-not $example.Contains('Get-Content') -and
|
||||
-not $example.Contains('fixtures') -and
|
||||
($example.Contains('|') -or $example.Contains('ConvertFrom-Json'))
|
||||
) {
|
||||
return $example
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($example in $Examples) {
|
||||
if (-not $example.Contains('Get-Content') -and -not $example.Contains('fixtures')) {
|
||||
return $example
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($example in $Examples) {
|
||||
if (-not $example.Contains('Get-Content')) {
|
||||
return $example
|
||||
}
|
||||
}
|
||||
|
||||
return $Examples[0]
|
||||
}
|
||||
|
||||
function Normalize-GeneratedExample {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Example
|
||||
)
|
||||
|
||||
return ($Example -replace '(?i)(?:[A-Z]:)?[^''"\s|]*fixtures(?:[\\/][^''"\s|]+)+', '<PATH>')
|
||||
}
|
||||
|
||||
function Test-ByteSequenceEqual {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[byte[]]$Left,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[byte[]]$Right
|
||||
)
|
||||
|
||||
if ($Left.Length -ne $Right.Length) {
|
||||
return $false
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $Left.Length; $index += 1) {
|
||||
if ($Left[$index] -ne $Right[$index]) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-CategorySpecs {
|
||||
return @(
|
||||
@{
|
||||
Title = 'Code and Context'
|
||||
Commands = @('fileprobe', 'outline', 'codeshape', 'refs', 'snip', 'defsnip', 'ctxpack', 'chunkcat', 'hitsnip', 'diagpick', 'gitshape', 'reposhape', 'dotnetshape')
|
||||
},
|
||||
@{
|
||||
Title = 'Data and Config'
|
||||
Commands = @('cjson', 'ison', 'isonl', 'zon', 'tonl', 'jsonlgrep', 'jsonshape', 'mhash', 'toon', 'csvshape', 'sqliteshape', 'sqlshape', 'config')
|
||||
},
|
||||
@{
|
||||
Title = 'Logs, Process, and Waiting'
|
||||
Commands = @('logshape', 'envdiff', 'proctree', 'sysshape', 'runprobe', 'await', 'argv', 'recent', 'pathshadow')
|
||||
},
|
||||
@{
|
||||
Title = 'Network and Locks'
|
||||
Commands = @('portping', 'portunlock', 'msudo', 'unlock')
|
||||
},
|
||||
@{
|
||||
Title = 'Managed, Unity, and Binary Inspection'
|
||||
Commands = @('asmtype', 'asmmember', 'asmref', 'asmapi', 'asmflow', 'llvmobjdump', 'llvmreadobj', 'llvmnm', 'peexports', 'peimports', 'pecalls', 'pesig', 'pestrrefs', 'drvshape', 'ioctlscan', 'unityasset', 'unityprobe', 'unitydiag', 'binmeta', 'stringscan')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function Build-SkillContent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Notes
|
||||
)
|
||||
|
||||
$builder = [System.Text.StringBuilder]::new()
|
||||
[void]$builder.AppendLine('---')
|
||||
[void]$builder.AppendLine('name: mercury-toolbox')
|
||||
[void]$builder.AppendLine("description: 'Use whenever Mercury Toolbox binaries are present and the task is local terminal inspection or triage: code and log reading, structured-data shaping, repo or runtime diagnosis, process, port, or lock troubleshooting, managed assembly or Unity analysis, or when the agent would otherwise reach for Get-Content, cat, tree, grep, findstr, netstat, tasklist, or ad-hoc PowerShell glue.'")
|
||||
[void]$builder.AppendLine('---')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('# Mercury Toolbox')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('Use this skill immediately when Mercury Toolbox binaries are present and the job is local terminal inspection, shaping, or triage. Read it first, then route through Mercury before falling back to raw dumps, legacy builtins, or ad-hoc shell glue.')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## First Choice Rules')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('- Prefer Mercury readers over `Get-Content`, `cat`, `tree`, `tasklist`, `netstat`, or whole-file dumps.')
|
||||
[void]$builder.AppendLine('- For source and logs, usually start with `fileprobe`, `outline`, `snip`, or `chunkcat`, then move to `hitsnip`, `defsnip`, `refs`, `codeshape`, or `ctxpack` as needed.')
|
||||
[void]$builder.AppendLine('- Prefer compact text by default, switch to `--json` when the next step parses the result, and switch to `--toon` or `--format toon` when the next consumer is an AI/model.')
|
||||
[void]$builder.AppendLine('- Pipe external JSON into `toon` only when the producer is not a Mercury tool; TOON auto-detects JSON and emits dense TOON by default.')
|
||||
[void]$builder.AppendLine('- Prefer stdin and pipelines over reopening the same file repeatedly.')
|
||||
[void]$builder.AppendLine('- Pair Mercury with modern CLI companions instead of legacy builtins.')
|
||||
[void]$builder.AppendLine('- Treat `msudo` as the top-level high-risk toolbox command: start with `msudo status --json` or `--help` before considering any privileged launch.')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## Modern Pairings')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('- Search and files: `rg`, `fd`, and optionally `fzf` or `PSFzf` for interactive narrowing.')
|
||||
[void]$builder.AppendLine('- Viewing and editing: `bat`, `hexyl`, and `nvim` for bounded human reads instead of raw file dumps.')
|
||||
[void]$builder.AppendLine('- Data and text: `jq`, `yq`, and `sd` for structured queries and small safe rewrites.')
|
||||
[void]$builder.AppendLine('- Git and navigation: `git`, `lazygit`, `delta`, and `zoxide` when they answer the question faster than shell glue.')
|
||||
[void]$builder.AppendLine('- Stats and diagnostics: `tokei`, `eza`, `procs`, `dust`, and `hyperfine` for fast shape or performance checks.')
|
||||
[void]$builder.AppendLine('- Utilities: `xh`, `ouch`, and `tealdeer` for quick HTTP work, archives, and terse help.')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## Modern Default Replacements')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('- Use `rg` over recursive `grep` or broad `Select-String`.')
|
||||
[void]$builder.AppendLine('- Use `fd` over `Get-ChildItem -Recurse` when you only need file discovery.')
|
||||
[void]$builder.AppendLine('- Use `bat` or Mercury readers over raw `Get-Content` for bounded viewing.')
|
||||
[void]$builder.AppendLine('- Use `jq` or `yq` over manual JSON or YAML parsing.')
|
||||
[void]$builder.AppendLine('- Use `sd` for simple regex replacements and `xh` over `curl` for quick HTTP checks.')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## Fast Routing')
|
||||
[void]$builder.AppendLine()
|
||||
foreach ($category in Get-CategorySpecs) {
|
||||
$toolList = ($category.Commands | ForEach-Object { ('`{0}`' -f $_) }) -join ', '
|
||||
[void]$builder.AppendLine("- $($category.Title): $toolList")
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## Job Routing')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('- Unknown file: start with `fileprobe <PATH>`, then choose `binmeta`, `stringscan`, `snip`, or `chunkcat` from the detected shape.')
|
||||
[void]$builder.AppendLine('- Repository map: start with `reposhape . --json`, then use `codeshape`, `gitshape`, `dotnetshape`, `sqlshape`, or `ctxpack`.')
|
||||
[void]$builder.AppendLine('- Symbol or source context: start with `defsnip <SYMBOL> .` or `refs <SYMBOL> .`, then pack evidence with `hitsnip` or `ctxpack`.')
|
||||
[void]$builder.AppendLine('- Logs and failures: start with `diagpick <LOG>`, then use `logshape`, `snip --match`, or `runprobe`.')
|
||||
[void]$builder.AppendLine('- Managed or Unity DLL: start with `asmref diagnose <ASSEMBLY> --resolve-dir <DIR>`, then use `asmtype`, `asmmember`, `asmflow`, or `asmapi diff`.')
|
||||
[void]$builder.AppendLine('- Windows EXE/DLL: start with `peimports <PATH>`; every catalog entry includes guided answer/trust/next actions, and PE deep tools also expose runtime `report_quality` and `next_actions` in `--json`/`--toon`.')
|
||||
[void]$builder.AppendLine('- Windows driver: start with `drvshape <SYS>`, then follow `ioctlscan`, `peimports --category device_io`, `pecalls --category device_io`, or `pestrrefs`.')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## Read Next')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('- Read `references/command-catalog.md` beside this skill when you need command-by-command routing, usage, and examples.')
|
||||
[void]$builder.AppendLine('- In packaged installs, the skill lives under `share\\mercury-toolbox\\skills\\mercury-toolbox` and the generated prompt lives under `share\\mercury-toolbox\\docs\\ai`.')
|
||||
|
||||
return $builder.ToString()
|
||||
}
|
||||
|
||||
function Convert-ToonExample {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Example,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CommandName,
|
||||
[bool]$SupportsJson
|
||||
)
|
||||
|
||||
if (-not $SupportsJson -or $CommandName -eq 'toon') {
|
||||
return ''
|
||||
}
|
||||
|
||||
$prefix = ($Example -split '\|\s*ConvertFrom-Json', 2)[0].TrimEnd()
|
||||
if ($prefix -match '(?i)\b--format\s+toon\b') {
|
||||
return ([regex]::Replace($prefix, '(?i)\s+--format\s+toon\b', ' --toon')).TrimEnd()
|
||||
}
|
||||
if ($prefix.Contains('--json')) {
|
||||
$lastJson = $prefix.LastIndexOf('--json')
|
||||
return ($prefix.Remove($lastJson, 6).Insert($lastJson, '--toon')).TrimEnd()
|
||||
}
|
||||
else {
|
||||
$escapedCommand = [regex]::Escape($CommandName)
|
||||
if ($CommandName -eq 'tonl') {
|
||||
$withToon = [regex]::Replace($prefix, "(^|\|\s*)($escapedCommand\s+\S+)(\s|$)", '$1$2 --toon$3', 1)
|
||||
if ($withToon -cne $prefix) {
|
||||
return $withToon
|
||||
}
|
||||
}
|
||||
$withToon = [regex]::Replace($prefix, "(^|\|\s*)($escapedCommand)(\s|$)", '$1$2 --toon$3', 1)
|
||||
if ($withToon -ceq $prefix) {
|
||||
return ''
|
||||
}
|
||||
return $withToon
|
||||
}
|
||||
}
|
||||
|
||||
function Build-CatalogContent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Notes,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BinaryRoot
|
||||
)
|
||||
|
||||
$builder = [System.Text.StringBuilder]::new()
|
||||
[void]$builder.AppendLine('# Mercury Toolbox Command Catalog')
|
||||
[void]$builder.AppendLine('Use this catalog when choosing among nearby Mercury commands. Keep output compact, prefer `--json` for machine handoff, prefer `--toon` for model-facing structured output, and use `toon` for external JSON producers.')
|
||||
|
||||
foreach ($category in Get-CategorySpecs) {
|
||||
[void]$builder.AppendLine("## $($category.Title)")
|
||||
foreach ($commandName in $category.Commands) {
|
||||
$toolNotes = $Notes.tools[$commandName]
|
||||
if ($null -eq $toolNotes) {
|
||||
throw "Missing AI skill notes for tool '$commandName'"
|
||||
}
|
||||
|
||||
$helpText = (& (Join-Path $BinaryRoot "$commandName.exe") '--help' | Out-String).TrimEnd()
|
||||
$help = Get-ToolHelpSections -HelpText $helpText
|
||||
$usageLine = if ($toolNotes.ContainsKey('prompt_usage') -and -not [string]::IsNullOrWhiteSpace([string]$toolNotes.prompt_usage)) {
|
||||
[string]$toolNotes.prompt_usage
|
||||
} elseif ($help.Usage.Count -gt 0) {
|
||||
(([string[]]$help.Usage) -join ' | ')
|
||||
} else {
|
||||
"$commandName --help"
|
||||
}
|
||||
$exampleLine = if ($toolNotes.ContainsKey('prompt_example') -and -not [string]::IsNullOrWhiteSpace([string]$toolNotes.prompt_example)) {
|
||||
[string]$toolNotes.prompt_example
|
||||
} else {
|
||||
Select-CatalogExample -Examples ([string[]]$help.Examples)
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($exampleLine)) {
|
||||
$exampleLine = "$commandName --help"
|
||||
} else {
|
||||
$exampleLine = Normalize-GeneratedExample -Example $exampleLine
|
||||
}
|
||||
$supportsJson = $helpText.Contains('--json')
|
||||
$guided = $toolNotes.guided_triage
|
||||
if ($null -eq $guided) {
|
||||
throw "Missing guided_triage notes for tool '$commandName'"
|
||||
}
|
||||
$nextActions = ([string[]]$guided.next_actions) -join ' | '
|
||||
|
||||
[void]$builder.AppendLine(('### `{0}`' -f $commandName))
|
||||
[void]$builder.AppendLine(('Use: {0} Better than: {1}' -f $toolNotes.use_when, $toolNotes.why))
|
||||
[void]$builder.AppendLine(('Usage: `{0}`' -f $usageLine))
|
||||
[void]$builder.AppendLine(('Example: `{0}`' -f $exampleLine))
|
||||
[void]$builder.AppendLine(('Guided answer: {0}' -f $guided.answer))
|
||||
[void]$builder.AppendLine(('Trust basis: {0}' -f $guided.trust))
|
||||
[void]$builder.AppendLine(('Next actions: {0}' -f $nextActions))
|
||||
$toonExample = Convert-ToonExample -Example $exampleLine -CommandName $commandName -SupportsJson $supportsJson
|
||||
if (-not [string]::IsNullOrWhiteSpace($toonExample)) {
|
||||
[void]$builder.AppendLine(('TOON example: `{0}`' -f $toonExample))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $builder.ToString()
|
||||
}
|
||||
|
||||
function Build-OpenAiYamlContent {
|
||||
return @'
|
||||
interface:
|
||||
display_name: "Mercury Toolbox"
|
||||
short_description: "Route proactive local CLI triage through Mercury Toolbox"
|
||||
icon_small: "./assets/logo.png"
|
||||
icon_large: "./assets/logo.png"
|
||||
brand_color: "#35C2FF"
|
||||
default_prompt: "Use $mercury-toolbox first for local code, log, data, repo, process, port, lock, managed, or Unity triage, and pair it with modern CLI tools before falling back to raw dumps or legacy builtins."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
'@
|
||||
}
|
||||
|
||||
function Assert-GeneratedFileMatches {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
throw "Generated $Label is missing: $Path"
|
||||
}
|
||||
|
||||
$temporaryPath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
|
||||
try {
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($temporaryPath, (ConvertTo-LfText -Content $Content), $encoding)
|
||||
$existingBytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path))
|
||||
$generatedBytes = [System.IO.File]::ReadAllBytes($temporaryPath)
|
||||
if (-not (Test-ByteSequenceEqual -Left $existingBytes -Right $generatedBytes)) {
|
||||
throw "Generated $Label is out of date. Run pwsh -NoProfile -File .\scripts\generate-ai-skill.ps1"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-LfText {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content
|
||||
)
|
||||
|
||||
return $Content -replace "`r`n", "`n" -replace "`r", "`n"
|
||||
}
|
||||
|
||||
function Write-Utf8FileWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content,
|
||||
[int]$Attempts = 20,
|
||||
[int]$DelayMilliseconds = 100
|
||||
)
|
||||
|
||||
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||
try {
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($Path, (ConvertTo-LfText -Content $Content), $encoding)
|
||||
return
|
||||
}
|
||||
catch {
|
||||
if ($attempt -eq $Attempts) {
|
||||
throw
|
||||
}
|
||||
Start-Sleep -Milliseconds $DelayMilliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$profileName = Resolve-ToolboxProfileName -Configuration $Configuration
|
||||
$binaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $profileName)
|
||||
$dependencyRoot = Join-Path $binaryRoot 'deps'
|
||||
if (Test-Path -LiteralPath $dependencyRoot -PathType Container) {
|
||||
$env:PATH = "$dependencyRoot$([System.IO.Path]::PathSeparator)$env:PATH"
|
||||
}
|
||||
$cargoPath = 'cargo'
|
||||
$notes = Get-Content -Raw -LiteralPath $NotesPath | ConvertFrom-Json -AsHashtable
|
||||
|
||||
$missingBinary = $false
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $binaryRoot "$commandName.exe"))) {
|
||||
$missingBinary = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingBinary) {
|
||||
if ($SkipBuild) {
|
||||
throw "Required toolbox binaries are missing under $binaryRoot. Build profile $Configuration before running generate-ai-skill.ps1 -SkipBuild."
|
||||
}
|
||||
Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration
|
||||
}
|
||||
|
||||
$referencesRoot = Join-Path $SkillRoot 'references'
|
||||
$agentsRoot = Join-Path $SkillRoot 'agents'
|
||||
$skillPath = Join-Path $SkillRoot 'SKILL.md'
|
||||
$catalogPath = Join-Path $referencesRoot 'command-catalog.md'
|
||||
$openAiYamlPath = Join-Path $agentsRoot 'openai.yaml'
|
||||
|
||||
$skillContent = Build-SkillContent -Notes $notes
|
||||
$catalogContent = Build-CatalogContent -Notes $notes -BinaryRoot $binaryRoot
|
||||
$openAiYamlContent = Build-OpenAiYamlContent
|
||||
|
||||
if ($Check) {
|
||||
Assert-GeneratedFileMatches -Path $skillPath -Content $skillContent -Label 'Mercury Toolbox skill'
|
||||
Assert-GeneratedFileMatches -Path $catalogPath -Content $catalogContent -Label 'Mercury Toolbox command catalog'
|
||||
Assert-GeneratedFileMatches -Path $openAiYamlPath -Content $openAiYamlContent -Label 'Mercury Toolbox openai.yaml'
|
||||
Write-Host "AI skill is up to date: $SkillRoot"
|
||||
return
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $SkillRoot, $referencesRoot, $agentsRoot | Out-Null
|
||||
Write-Utf8FileWithRetry -Path $skillPath -Content $skillContent
|
||||
Write-Utf8FileWithRetry -Path $catalogPath -Content $catalogContent
|
||||
Write-Utf8FileWithRetry -Path $openAiYamlPath -Content $openAiYamlContent
|
||||
Write-Host "Generated AI skill: $SkillRoot"
|
||||
@@ -0,0 +1,100 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
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-OptionalCommandPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
$command = Get-Command -Name $Name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $command) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
function Resolve-XperfPath {
|
||||
$commandPath = Get-OptionalCommandPath -Name 'xperf'
|
||||
if (-not [string]::IsNullOrWhiteSpace($commandPath)) {
|
||||
return $commandPath
|
||||
}
|
||||
|
||||
$candidates = @()
|
||||
foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($root)) {
|
||||
$candidates += (Join-Path $root 'Windows Kits\10\Windows Performance Toolkit\xperf.exe')
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Ensure-CargoBinstall {
|
||||
if ($null -ne (Get-Command -Name 'cargo-binstall' -ErrorAction SilentlyContinue)) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host 'cargo-binstall is missing; installing it first so required Jade tools can use binary releases when available.'
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("install", "cargo-binstall", "--locked")
|
||||
}
|
||||
|
||||
function Test-IsElevated {
|
||||
if (-not $IsWindows) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
Invoke-StrictNative -FilePath rustup -ArgumentList @("toolchain", "install", "nightly")
|
||||
Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "miri", "--toolchain", "nightly")
|
||||
Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "rust-src", "--toolchain", "nightly")
|
||||
Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "llvm-tools-preview", "--toolchain", "stable")
|
||||
Invoke-StrictNative -FilePath rustup -ArgumentList @("component", "add", "llvm-tools-preview", "--toolchain", "nightly")
|
||||
Ensure-CargoBinstall
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "cargo-nextest", "cargo-deny", "cargo-udeps", "cargo-llvm-cov", "cargo-expand", "cargo-bloat", "cargo-asm", "cargo-llvm-lines", "cargo-geiger", "cargo-outdated", "cargo-audit")
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("install", "cargo-fuzz", "--locked")
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "flamegraph")
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "samply")
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "sccache")
|
||||
Invoke-StrictNative -FilePath cargo -ArgumentList @("binstall", "-y", "just", "bacon")
|
||||
if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
|
||||
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber -Repository PSGallery
|
||||
}
|
||||
|
||||
if ($IsWindows) {
|
||||
if (-not (Test-IsElevated)) {
|
||||
Write-Warning 'cargo flamegraph falls back to blondie on Windows when DTrace is not configured, and blondie requires an elevated shell.'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace((Resolve-XperfPath))) {
|
||||
Write-Warning 'samply is installed, but recording on Windows still needs xperf from Windows Performance Toolkit (WPT).'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'),
|
||||
[string]$CodexHome,
|
||||
[switch]$NoCodexSkillInstall,
|
||||
[switch]$NoPathUpdate
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot 'toolbox-commands.ps1')
|
||||
|
||||
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 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 Get-PathWithTrailingSeparator {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$trimmed = $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
return $trimmed + [System.IO.Path]::DirectorySeparatorChar
|
||||
}
|
||||
|
||||
function Assert-PackageSha256Sums {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PackageRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$HashPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $HashPath)) {
|
||||
throw "Package checksum file not found: $HashPath"
|
||||
}
|
||||
|
||||
$packageRootItem = Get-Item -LiteralPath $PackageRoot -Force
|
||||
if (($packageRootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Package root must not be a reparse point: $PackageRoot"
|
||||
}
|
||||
|
||||
$packageReparsePoints = @(Get-ChildItem -LiteralPath $PackageRoot -Force -Recurse -Attributes ReparsePoint)
|
||||
if ($packageReparsePoints.Count -gt 0) {
|
||||
throw "Package tree contains reparse points: $($packageReparsePoints[0].FullName)"
|
||||
}
|
||||
|
||||
$resolvedPackageRoot = [System.IO.Path]::GetFullPath($PackageRoot)
|
||||
$packageRootPrefix = Get-PathWithTrailingSeparator -Path $resolvedPackageRoot
|
||||
$listedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$lineNumber = 0
|
||||
$entryCount = 0
|
||||
foreach ($line in Get-Content -LiteralPath $HashPath) {
|
||||
$lineNumber++
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
continue
|
||||
}
|
||||
if ($line -notmatch '^(?<hash>[A-Fa-f0-9]{64})\s+\*?(?<path>.+)$') {
|
||||
throw "Invalid checksum entry at ${HashPath}:$lineNumber"
|
||||
}
|
||||
|
||||
$relativePath = $Matches.path.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($relativePath) -or [System.IO.Path]::IsPathRooted($relativePath)) {
|
||||
throw "Invalid checksum path at ${HashPath}:$lineNumber"
|
||||
}
|
||||
|
||||
$filePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedPackageRoot $relativePath))
|
||||
if (-not $filePath.StartsWith($packageRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Checksum path escapes package root at ${HashPath}:$lineNumber"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) {
|
||||
throw "Package checksum listed file is missing: $relativePath"
|
||||
}
|
||||
$normalizedRelativePath = [System.IO.Path]::GetRelativePath($resolvedPackageRoot, $filePath)
|
||||
if (-not $listedFiles.Add($normalizedRelativePath)) {
|
||||
throw "Duplicate checksum entry for package file: $normalizedRelativePath"
|
||||
}
|
||||
|
||||
$expectedHash = $Matches.hash.ToLowerInvariant()
|
||||
$actualHash = (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw "Package checksum mismatch for ${relativePath}: expected $expectedHash, got $actualHash"
|
||||
}
|
||||
$entryCount++
|
||||
}
|
||||
|
||||
if ($entryCount -eq 0) {
|
||||
throw "Package checksum file contains no entries: $HashPath"
|
||||
}
|
||||
|
||||
foreach ($file in Get-ChildItem -LiteralPath $PackageRoot -Force -Recurse -File) {
|
||||
if ($file.FullName -eq $HashPath) {
|
||||
continue
|
||||
}
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($resolvedPackageRoot, $file.FullName)
|
||||
if (-not $listedFiles.Contains($relativePath)) {
|
||||
throw "Package contains file missing from SHA256SUMS: $relativePath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||
$suffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
return "$stamp-package-$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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
$packageRoot = Split-Path -Parent $PSScriptRoot
|
||||
$binSource = Join-Path $packageRoot 'bin'
|
||||
$docsSource = Join-Path $packageRoot 'docs'
|
||||
$skillsSource = Join-Path $packageRoot 'skills'
|
||||
$supportSource = Join-Path $packageRoot 'support'
|
||||
$manifestSource = Join-Path $packageRoot 'mercury-toolbox-package.json'
|
||||
$hashSource = Join-Path $packageRoot 'SHA256SUMS.txt'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $binSource)) {
|
||||
throw "Package bin directory not found: $binSource"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $docsSource)) {
|
||||
throw "Package docs directory not found: $docsSource"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $skillsSource)) {
|
||||
throw "Package skills directory not found: $skillsSource"
|
||||
}
|
||||
|
||||
$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)
|
||||
$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-PackageSha256Sums -PackageRoot $packageRoot -HashPath $hashSource
|
||||
Assert-CodexSkillInstallAllowed -SkillRoot $codexSkillRoot
|
||||
|
||||
Write-Host "Mercury Toolbox package installer"
|
||||
Write-Host "package root: $packageRoot"
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
$installed = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
$sourcePath = Join-Path $binSource "$commandName.exe"
|
||||
if (-not (Test-Path -LiteralPath $sourcePath)) {
|
||||
throw "Expected packaged binary not found: $sourcePath"
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $stagingBinDir "$commandName.exe") -Force
|
||||
$installed.Add($commandName)
|
||||
}
|
||||
|
||||
foreach ($runtimeFile in Get-ChildItem -LiteralPath $binSource -Filter '*.dll' -File -ErrorAction SilentlyContinue) {
|
||||
Copy-Item -LiteralPath $runtimeFile.FullName -Destination (Join-Path $stagingBinDir $runtimeFile.Name) -Force
|
||||
}
|
||||
|
||||
Copy-DirectoryTree -SourcePath $docsSource -DestinationPath (Join-Path $shareRoot 'docs')
|
||||
Copy-DirectoryTree -SourcePath $skillsSource -DestinationPath (Join-Path $shareRoot 'skills')
|
||||
if (Test-Path -LiteralPath $supportSource) {
|
||||
Copy-DirectoryTree -SourcePath $supportSource -DestinationPath (Join-Path $shareRoot 'support')
|
||||
}
|
||||
Copy-OptionalFile -SourcePath $manifestSource -DestinationPath (Join-Path $shareRoot 'mercury-toolbox-package.json')
|
||||
Copy-OptionalFile -SourcePath $hashSource -DestinationPath (Join-Path $shareRoot 'SHA256SUMS.txt')
|
||||
|
||||
if ($null -ne $codexSkillRoot) {
|
||||
$packagedSkillRoot = Join-Path $skillsSource 'mercury-toolbox'
|
||||
if (Test-Path -LiteralPath $packagedSkillRoot) {
|
||||
Copy-DirectoryTree -SourcePath $packagedSkillRoot -DestinationPath $codexSkillRoot
|
||||
Write-CodexSkillOwnershipMarker -SkillRoot $codexSkillRoot -InstallKind 'package'
|
||||
}
|
||||
}
|
||||
|
||||
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 "Shared skill copy: $(Join-Path $shareRoot 'skills\mercury-toolbox\SKILL.md')"
|
||||
Write-Host "Command catalog: $(Join-Path $shareRoot 'skills\mercury-toolbox\references\command-catalog.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'
|
||||
@@ -0,0 +1,507 @@
|
||||
[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'
|
||||
@@ -0,0 +1,189 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Remote = 'origin',
|
||||
[string]$BaseUrl,
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
[string]$UserName,
|
||||
[string]$ApiToken,
|
||||
[switch]$InsecureSkipTlsVerify,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$GitArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Invoke-GitCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$output = & git @ArgumentList 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")"
|
||||
}
|
||||
|
||||
return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Resolve-GiteaRepositoryContext {
|
||||
param(
|
||||
[string]$RemoteName,
|
||||
[string]$RemoteUrl
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RemoteUrl)) {
|
||||
$RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', $RemoteName)
|
||||
}
|
||||
|
||||
$normalized = $RemoteUrl.Trim()
|
||||
if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = $Matches[1]
|
||||
Owner = $Matches[2]
|
||||
Repo = $Matches[3]
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
$scheme = 'https'
|
||||
$giteaHost = $Matches[1]
|
||||
$port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" }
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = "${scheme}://$giteaHost$port"
|
||||
Owner = $Matches[3]
|
||||
Repo = $Matches[4]
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not parse Gitea remote URL: $normalized"
|
||||
}
|
||||
|
||||
function Resolve-ApiToken {
|
||||
param(
|
||||
[string]$ExplicitToken
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) {
|
||||
return $ExplicitToken
|
||||
}
|
||||
|
||||
foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
return $value
|
||||
}
|
||||
}
|
||||
|
||||
throw 'No Gitea token was provided. Pass -ApiToken or set GITEA_API_TOKEN/GITEA_TOKEN for this process; this helper deliberately does not call Git Credential Manager.'
|
||||
}
|
||||
|
||||
function Get-ScopedEnvironment {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Names
|
||||
)
|
||||
|
||||
$values = @{}
|
||||
foreach ($name in $Names) {
|
||||
$values[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
}
|
||||
|
||||
return $values
|
||||
}
|
||||
|
||||
function Set-ScopedEnvironment {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Values
|
||||
)
|
||||
|
||||
foreach ($entry in $Values.GetEnumerator()) {
|
||||
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
|
||||
}
|
||||
}
|
||||
|
||||
function Write-GitOutput {
|
||||
param(
|
||||
[object[]]$Output
|
||||
)
|
||||
|
||||
foreach ($item in $Output) {
|
||||
$line = [string]$item
|
||||
if ($line -match '^\d{2}:\d{2}:\d{2}\.\d+\s+http\.c:\d+\s+') {
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Output $line
|
||||
}
|
||||
}
|
||||
|
||||
if ($GitArgs.Count -gt 0 -and $GitArgs[0] -eq '--') {
|
||||
$GitArgs = @($GitArgs | Select-Object -Skip 1)
|
||||
}
|
||||
|
||||
if ($GitArgs.Count -eq 0) {
|
||||
throw 'Pass the git subcommand, for example: .\scripts\invoke-gitea-git.ps1 ls-remote origin refs/heads/main'
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location -LiteralPath $workspaceRoot
|
||||
|
||||
try {
|
||||
$context = Resolve-GiteaRepositoryContext -RemoteName $Remote
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
$BaseUrl = $context.BaseUrl
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Owner)) {
|
||||
$Owner = $context.Owner
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Repo)) {
|
||||
$Repo = $context.Repo
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
||||
$UserName = $Owner
|
||||
}
|
||||
|
||||
$resolvedToken = Resolve-ApiToken -ExplicitToken $ApiToken
|
||||
$basic = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${UserName}:$resolvedToken"))
|
||||
$configArgs = @(
|
||||
'-c', 'credential.helper=',
|
||||
'-c', 'http.sslBackend=openssl',
|
||||
'-c', "http.extraHeader=Authorization: Basic $basic"
|
||||
)
|
||||
if ($InsecureSkipTlsVerify) {
|
||||
$configArgs += @('-c', 'http.sslVerify=false')
|
||||
}
|
||||
|
||||
$scopedEnvironmentValues = @{
|
||||
GIT_TERMINAL_PROMPT = '0'
|
||||
GIT_TRACE = '0'
|
||||
GIT_TRACE2 = '0'
|
||||
GIT_TRACE2_EVENT = '0'
|
||||
GIT_TRACE2_PERF = '0'
|
||||
GIT_TRACE_PACKET = '0'
|
||||
GIT_TRACE_CURL = '0'
|
||||
GIT_CURL_VERBOSE = $null
|
||||
}
|
||||
$scopedNames = @($scopedEnvironmentValues.Keys)
|
||||
$previousEnvironment = Get-ScopedEnvironment -Names $scopedNames
|
||||
Set-ScopedEnvironment -Values $scopedEnvironmentValues
|
||||
|
||||
try {
|
||||
$gitOutput = @(& git @configArgs @GitArgs 2>&1)
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
Set-ScopedEnvironment -Values $previousEnvironment
|
||||
}
|
||||
|
||||
Write-GitOutput -Output $gitOutput
|
||||
if ($exitCode -ne 0) {
|
||||
throw "git command failed with exit code $exitCode. The transient Authorization header was not printed or persisted."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$Configuration = 'ReleaseFast',
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'dist'),
|
||||
[string]$PackageName,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipSlimBinaryRebuild,
|
||||
[switch]$SkipPromptGeneration
|
||||
)
|
||||
|
||||
$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 Get-HostTargetTriple {
|
||||
$rustcPath = Get-RequiredCommandPath -Name 'rustc'
|
||||
$lines = & $rustcPath -vV
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$commandLine = Format-ToolboxNativeCommand -FilePath $rustcPath -ArgumentList @('-vV')
|
||||
throw "Failed to query rustc host target with command: $commandLine"
|
||||
}
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line -like 'host:*') {
|
||||
return ($line -replace '^host:\s*', '').Trim()
|
||||
}
|
||||
}
|
||||
|
||||
throw 'rustc host target was not reported'
|
||||
}
|
||||
|
||||
function Assert-ArchiveCreated {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Compress-Archive did not create the expected archive: $Path"
|
||||
}
|
||||
|
||||
$archive = Get-Item -LiteralPath $Path -Force
|
||||
if ($archive.Length -le 0) {
|
||||
throw "Compress-Archive created an empty archive: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Reset-Directory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||
}
|
||||
|
||||
function Assert-SingleDirectoryName {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Name)) {
|
||||
throw 'PackageName must not be empty.'
|
||||
}
|
||||
if ([System.IO.Path]::IsPathRooted($Name) -or $Name -ne [System.IO.Path]::GetFileName($Name) -or $Name -in @('.', '..')) {
|
||||
throw "PackageName must be a single directory name, not a path: $Name"
|
||||
}
|
||||
if ($Name.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
|
||||
throw "PackageName contains invalid file name characters: $Name"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PathWithTrailingSeparator {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$trimmed = $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
return $trimmed + [System.IO.Path]::DirectorySeparatorChar
|
||||
}
|
||||
|
||||
function Resolve-PackageRootPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
Assert-SingleDirectoryName -Name $Name
|
||||
$resolvedRoot = [System.IO.Path]::GetFullPath($RootPath)
|
||||
if (Test-Path -LiteralPath $resolvedRoot) {
|
||||
$rootItem = Get-Item -LiteralPath $resolvedRoot -Force
|
||||
if (($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "OutputRoot must not be a reparse point: $resolvedRoot"
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedPackageRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $Name))
|
||||
$rootPrefix = Get-PathWithTrailingSeparator -Path $resolvedRoot
|
||||
if (-not $resolvedPackageRoot.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Resolved package root must stay inside OutputRoot: $resolvedPackageRoot"
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
OutputRoot = $resolvedRoot
|
||||
PackageRoot = $resolvedPackageRoot
|
||||
}
|
||||
}
|
||||
|
||||
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 Write-Sha256SumsFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$lines = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($file in (Get-ChildItem -LiteralPath $RootPath -Recurse -File | Sort-Object FullName)) {
|
||||
if ($file.FullName -eq $OutputPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($RootPath, $file.FullName).Replace('\', '/')
|
||||
$hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$lines.Add("$hash $relativePath")
|
||||
}
|
||||
|
||||
Set-Content -LiteralPath $OutputPath -Value $lines -Encoding utf8NoBOM
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$profileName = Resolve-ToolboxProfileName -Configuration $Configuration
|
||||
$binaryRoot = Join-Path $workspaceRoot (Join-Path 'target' $profileName)
|
||||
$hostTarget = Get-HostTargetTriple
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PackageName)) {
|
||||
$PackageName = "MercuryToolbox-$hostTarget-$Configuration"
|
||||
}
|
||||
|
||||
$resolvedPackagePaths = Resolve-PackageRootPath -RootPath $OutputRoot -Name $PackageName
|
||||
$OutputRoot = $resolvedPackagePaths.OutputRoot
|
||||
$packageRoot = $resolvedPackagePaths.PackageRoot
|
||||
$zipPath = Join-Path $OutputRoot "$PackageName.zip"
|
||||
$binDir = Join-Path $packageRoot 'bin'
|
||||
$docsAiDir = Join-Path $packageRoot 'docs\ai'
|
||||
$skillsDir = Join-Path $packageRoot 'skills'
|
||||
$scriptsDir = Join-Path $packageRoot 'scripts'
|
||||
$supportDir = Join-Path $packageRoot 'support'
|
||||
$startHerePath = Join-Path $packageRoot 'START-HERE.txt'
|
||||
|
||||
Write-Host "Mercury Toolbox release packager"
|
||||
Write-Host "workspace: $workspaceRoot"
|
||||
Write-Host "configuration: $Configuration"
|
||||
Write-Host "host target: $hostTarget"
|
||||
Write-Host "output root: $OutputRoot"
|
||||
Write-Host "package root: $packageRoot"
|
||||
Write-Host "archive: $zipPath"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null
|
||||
|
||||
$cargoPath = Get-RequiredCommandPath -Name 'cargo'
|
||||
if (-not $SkipBuild) {
|
||||
Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $Configuration -SkipSlimBinaryRebuild:$SkipSlimBinaryRebuild
|
||||
}
|
||||
|
||||
if (-not $SkipPromptGeneration) {
|
||||
$promptScript = Join-Path $PSScriptRoot 'generate-ai-prompt.ps1'
|
||||
$skillScript = Join-Path $PSScriptRoot 'generate-ai-skill.ps1'
|
||||
$promptArgs = @{
|
||||
Configuration = $Configuration
|
||||
}
|
||||
if ($SkipBuild) {
|
||||
$promptArgs.SkipBuild = $true
|
||||
}
|
||||
& $promptScript @promptArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "generate-ai-prompt.ps1 exited with $LASTEXITCODE"
|
||||
}
|
||||
& $skillScript @promptArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "generate-ai-skill.ps1 exited with $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Reset-Directory -Path $packageRoot
|
||||
New-Item -ItemType Directory -Force -Path $binDir, $docsAiDir, $skillsDir, $scriptsDir | Out-Null
|
||||
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
$binaryPath = Join-Path $binaryRoot "$commandName.exe"
|
||||
if (-not (Test-Path -LiteralPath $binaryPath)) {
|
||||
throw "Expected binary not found: $binaryPath"
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $binaryPath -Destination (Join-Path $binDir "$commandName.exe") -Force
|
||||
}
|
||||
|
||||
$duckdbDllPath = Join-Path $binaryRoot 'deps\duckdb.dll'
|
||||
if (-not (Test-Path -LiteralPath $duckdbDllPath)) {
|
||||
throw "Expected DuckDB runtime not found: $duckdbDllPath"
|
||||
}
|
||||
Copy-Item -LiteralPath $duckdbDllPath -Destination (Join-Path $binDir 'duckdb.dll') -Force
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $workspaceRoot 'LICENSE') -Destination (Join-Path $packageRoot 'LICENSE') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $workspaceRoot 'docs\ai\mercury-toolbox-ai-prompt.md') -Destination (Join-Path $docsAiDir 'mercury-toolbox-ai-prompt.md') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $workspaceRoot 'docs\ai\toolbox-ai-prompt-notes.json') -Destination (Join-Path $docsAiDir 'toolbox-ai-prompt-notes.json') -Force
|
||||
Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'skills\mercury-toolbox') -DestinationPath (Join-Path $skillsDir 'mercury-toolbox')
|
||||
if (Test-Path -LiteralPath (Join-Path $workspaceRoot 'support')) {
|
||||
Copy-DirectoryTree -SourcePath (Join-Path $workspaceRoot 'support') -DestinationPath $supportDir
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'toolbox-commands.ps1') -Destination (Join-Path $scriptsDir 'toolbox-commands.ps1') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install-package-toolbox.ps1') -Destination (Join-Path $scriptsDir 'install-package-toolbox.ps1') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall-package-toolbox.ps1') -Destination (Join-Path $scriptsDir 'uninstall-package-toolbox.ps1') -Force
|
||||
|
||||
$startHereLines = @(
|
||||
'Mercury Toolbox portable package',
|
||||
'',
|
||||
'Install from this extracted directory:',
|
||||
' pwsh -NoProfile -File .\scripts\install-package-toolbox.ps1',
|
||||
'',
|
||||
'After install, open a new PowerShell or reload PATH:',
|
||||
' $env:Path = [Environment]::GetEnvironmentVariable(''Path'',''User'') + '';'' + [Environment]::GetEnvironmentVariable(''Path'',''Machine'')',
|
||||
'',
|
||||
'Safe first commands:',
|
||||
' fileprobe <PATH>',
|
||||
' reposhape . --json | ConvertFrom-Json',
|
||||
' peimports <PATH> --json | ConvertFrom-Json',
|
||||
' drvshape <SYS> --json | ConvertFrom-Json',
|
||||
' asmref diagnose <ASSEMBLY> --resolve-dir <DIR> --format toon',
|
||||
'',
|
||||
'AI and Codex assets:',
|
||||
' docs\ai\mercury-toolbox-ai-prompt.md',
|
||||
' skills\mercury-toolbox\SKILL.md',
|
||||
' skills\mercury-toolbox\references\command-catalog.md',
|
||||
'',
|
||||
'Trust guided triage first. Every command is documented with an answer shape, trust basis, and next actions in the packaged skill catalog. PE tools also expose report_quality and next_actions in --json/--toon, and text output starts with answer=, trust=, and next_action= lines.',
|
||||
'',
|
||||
'This archive intentionally omits the repo README; use the files above as the package entry points.'
|
||||
)
|
||||
Set-Content -LiteralPath $startHerePath -Value $startHereLines -Encoding utf8NoBOM
|
||||
|
||||
$gitCommit = (& git -C $workspaceRoot rev-parse HEAD 2>$null)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$gitCommit = $null
|
||||
}
|
||||
|
||||
$manifest = [ordered]@{
|
||||
name = 'Mercury Toolbox'
|
||||
package_name = $PackageName
|
||||
configuration = $Configuration
|
||||
profile = $profileName
|
||||
host_target = $hostTarget
|
||||
built_at_utc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
git_commit = if ([string]::IsNullOrWhiteSpace($gitCommit)) { $null } else { $gitCommit.Trim() }
|
||||
command_count = (Get-ToolboxCommandNames).Count
|
||||
commands = @(Get-ToolboxCommandNames)
|
||||
install_script = 'scripts/install-package-toolbox.ps1'
|
||||
uninstall_script = 'scripts/uninstall-package-toolbox.ps1'
|
||||
ai_prompt = 'docs/ai/mercury-toolbox-ai-prompt.md'
|
||||
ai_skill = 'skills/mercury-toolbox/SKILL.md'
|
||||
ai_skill_catalog = 'skills/mercury-toolbox/references/command-catalog.md'
|
||||
}
|
||||
$manifestPath = Join-Path $packageRoot 'mercury-toolbox-package.json'
|
||||
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM
|
||||
|
||||
$hashPath = Join-Path $packageRoot 'SHA256SUMS.txt'
|
||||
Write-Sha256SumsFile -RootPath $packageRoot -OutputPath $hashPath
|
||||
|
||||
if (Test-Path -LiteralPath $zipPath) {
|
||||
Remove-Item -LiteralPath $zipPath -Force
|
||||
}
|
||||
Compress-Archive -LiteralPath $packageRoot -DestinationPath $zipPath -CompressionLevel Optimal
|
||||
Assert-ArchiveCreated -Path $zipPath
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Packaged commands ($((Get-ToolboxCommandNames).Count)):"
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
Write-Host " - $commandName"
|
||||
}
|
||||
Write-Host "Portable install: $(Join-Path $PackageName 'scripts\install-package-toolbox.ps1')"
|
||||
Write-Host "AI prompt: $(Join-Path $PackageName 'docs\ai\mercury-toolbox-ai-prompt.md')"
|
||||
Write-Host "Codex skill: $(Join-Path $PackageName 'skills\mercury-toolbox\SKILL.md')"
|
||||
Write-Host "Command catalog: $(Join-Path $PackageName 'skills\mercury-toolbox\references\command-catalog.md')"
|
||||
Write-Host "Archive ready: $zipPath"
|
||||
@@ -0,0 +1,510 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$BuildProfile = 'ReleaseFast',
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$FlamegraphBuildProfile = 'Release',
|
||||
[int]$Warmup = 2,
|
||||
[int]$Runs = 6,
|
||||
[switch]$SkipBenchmarks,
|
||||
[switch]$IncludeBloat,
|
||||
[switch]$IncludeLlvmLines,
|
||||
[switch]$IncludeBuildTimings,
|
||||
[switch]$IncludeFlamegraph,
|
||||
[string[]]$FlamegraphPackages = @('codeshape', 'refs')
|
||||
)
|
||||
|
||||
$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 Get-OptionalCommandPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
$command = Get-Command -Name $Name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $command) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
function Resolve-XperfPath {
|
||||
$commandPath = Get-OptionalCommandPath -Name 'xperf'
|
||||
if (-not [string]::IsNullOrWhiteSpace($commandPath)) {
|
||||
return $commandPath
|
||||
}
|
||||
|
||||
$candidates = @()
|
||||
foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($root)) {
|
||||
$candidates += (Join-Path $root 'Windows Kits\10\Windows Performance Toolkit\xperf.exe')
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-IsElevated {
|
||||
if (-not $IsWindows) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-FlamegraphCapability {
|
||||
$capability = [ordered]@{
|
||||
platform = if ($IsWindows) { 'windows' } else { 'other' }
|
||||
can_attempt = $true
|
||||
cargo_flamegraph_backend = if ($IsWindows) { 'unknown' } else { 'platform-default' }
|
||||
is_elevated = $false
|
||||
elevation_wrapper = '.\scripts\cargo-flamegraph-windows.ps1'
|
||||
dtrace_path = $null
|
||||
dtrace_source = $null
|
||||
xperf_path = $null
|
||||
samply_path = $null
|
||||
samply_available = $false
|
||||
reason = 'Non-Windows platform; defer to cargo flamegraph defaults.'
|
||||
note = $null
|
||||
}
|
||||
|
||||
if (-not $IsWindows) {
|
||||
return [pscustomobject]$capability
|
||||
}
|
||||
|
||||
$capability.is_elevated = Test-IsElevated
|
||||
$capability.samply_path = Get-OptionalCommandPath -Name 'samply'
|
||||
$capability.xperf_path = Resolve-XperfPath
|
||||
$capability.samply_available = (
|
||||
-not [string]::IsNullOrWhiteSpace([string]$capability.samply_path) -and
|
||||
-not [string]::IsNullOrWhiteSpace([string]$capability.xperf_path)
|
||||
)
|
||||
|
||||
$dtraceOverride = $env:DTRACE
|
||||
if (-not [string]::IsNullOrWhiteSpace($dtraceOverride)) {
|
||||
$resolvedOverride = $null
|
||||
if (Test-Path -LiteralPath $dtraceOverride) {
|
||||
$resolvedOverride = (Resolve-Path -LiteralPath $dtraceOverride).Path
|
||||
}
|
||||
else {
|
||||
$resolvedCommand = Get-Command -Name $dtraceOverride -ErrorAction SilentlyContinue
|
||||
if ($null -ne $resolvedCommand) {
|
||||
$resolvedOverride = $resolvedCommand.Source
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($resolvedOverride)) {
|
||||
$capability.dtrace_path = $resolvedOverride
|
||||
$capability.dtrace_source = 'env:DTRACE'
|
||||
}
|
||||
else {
|
||||
$capability.note = "DTRACE is set but does not currently resolve: $dtraceOverride"
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$capability.dtrace_path)) {
|
||||
$dtracePath = Get-OptionalCommandPath -Name 'dtrace'
|
||||
if (-not [string]::IsNullOrWhiteSpace($dtracePath)) {
|
||||
$capability.dtrace_path = $dtracePath
|
||||
$capability.dtrace_source = 'PATH'
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$capability.dtrace_path)) {
|
||||
$capability.cargo_flamegraph_backend = 'dtrace'
|
||||
$capability.reason = 'DTrace detected; cargo flamegraph will prefer DTrace on Windows.'
|
||||
if (-not $capability.is_elevated -and [string]::IsNullOrWhiteSpace([string]$capability.note)) {
|
||||
$capability.note = 'Microsoft documents DTrace setup and validation from an elevated prompt; if collection fails, rerun from an elevated shell.'
|
||||
}
|
||||
|
||||
return [pscustomobject]$capability
|
||||
}
|
||||
|
||||
$capability.cargo_flamegraph_backend = 'blondie'
|
||||
if ($capability.is_elevated) {
|
||||
$capability.reason = 'No DTrace detected; cargo flamegraph will fall back to blondie, and the shell is elevated.'
|
||||
}
|
||||
else {
|
||||
$capability.can_attempt = $false
|
||||
$capability.reason = 'No DTrace detected; cargo flamegraph will fall back to blondie, which requires an elevated shell on Windows because it uses the ETW Kernel Logger session.'
|
||||
if ($capability.samply_available) {
|
||||
$capability.note = 'Use .\scripts\cargo-flamegraph-windows.ps1 for an auto-elevated one-shot flamegraph. samply is also available separately because xperf is installed, but cargo flamegraph itself still needs elevation or DTrace.'
|
||||
}
|
||||
elseif ([string]::IsNullOrWhiteSpace([string]$capability.note)) {
|
||||
$capability.note = 'Use .\scripts\cargo-flamegraph-windows.ps1 for an auto-elevated one-shot flamegraph. samply is not a usable fallback on this machine because xperf from Windows Performance Toolkit is not installed.'
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]$capability
|
||||
}
|
||||
|
||||
function Get-BinaryTable {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetDir
|
||||
)
|
||||
|
||||
return @(
|
||||
Get-ChildItem (Join-Path $WorkspaceRoot $TargetDir) -Filter '*.exe' |
|
||||
Sort-Object Length -Descending |
|
||||
Select-Object Name,
|
||||
@{ Name = 'SizeKB'; Expression = { [math]::Round($_.Length / 1KB, 1) } },
|
||||
@{ Name = 'SizeBytes'; Expression = { $_.Length } }
|
||||
)
|
||||
}
|
||||
|
||||
function New-BenchmarkCommands {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetDir
|
||||
)
|
||||
|
||||
$binaryRoot = Join-Path $WorkspaceRoot $TargetDir
|
||||
$fixtureRoot = Join-Path $WorkspaceRoot 'fixtures'
|
||||
|
||||
return @(
|
||||
('"{0}" --sort-keys "{1}"' -f (Join-Path $binaryRoot 'cjson.exe'), (Join-Path $fixtureRoot 'cjson\sample.json')),
|
||||
('"{0}" level=error "{1}" --pick ts,msg' -f (Join-Path $binaryRoot 'jsonlgrep.exe'), (Join-Path $fixtureRoot 'jsonl\events.jsonl')),
|
||||
('"{0}" "{1}" --json' -f (Join-Path $binaryRoot 'jsonshape.exe'), (Join-Path $fixtureRoot 'toon\config.json')),
|
||||
('"{0}" --root "{1}" --since 9999h --ext rs --limit 20' -f (Join-Path $binaryRoot 'recent.exe'), $fixtureRoot),
|
||||
('"{0}" "{1}" --max-depth 2 --limit-per-file 12' -f (Join-Path $binaryRoot 'codeshape.exe'), (Join-Path $fixtureRoot 'polyglot\repo')),
|
||||
('"{0}" helper "{1}" --limit 6' -f (Join-Path $binaryRoot 'defsnip.exe'), (Join-Path $fixtureRoot 'polyglot\repo')),
|
||||
('"{0}" helper "{1}" --limit 12' -f (Join-Path $binaryRoot 'refs.exe'), (Join-Path $fixtureRoot 'polyglot\repo')),
|
||||
('"{0}" --context 1 "{1}:18" "{1}:26"' -f (Join-Path $binaryRoot 'hitsnip.exe'), (Join-Path $fixtureRoot 'reading\sample.rs')),
|
||||
('"{0}" "{1}"' -f (Join-Path $binaryRoot 'stringscan.exe'), (Join-Path $fixtureRoot 'binaries\stringscan-sample.bin')),
|
||||
('"{0}" "{1}" --json' -f (Join-Path $binaryRoot 'toon.exe'), (Join-Path $fixtureRoot 'toon\config.toon')),
|
||||
('"{0}" rg' -f (Join-Path $binaryRoot 'pathshadow.exe')),
|
||||
('"{0}" --env none --group shell' -f (Join-Path $binaryRoot 'sysshape.exe'))
|
||||
)
|
||||
}
|
||||
|
||||
function Get-FlamegraphTarget {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Package,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot
|
||||
)
|
||||
|
||||
$fixtureRoot = Join-Path $WorkspaceRoot 'fixtures'
|
||||
|
||||
switch ($Package) {
|
||||
'codeshape' {
|
||||
return @{
|
||||
Package = 'codeshape'
|
||||
Binary = 'codeshape'
|
||||
Arguments = @((Join-Path $fixtureRoot 'polyglot\repo'), '--max-depth', '2', '--limit-per-file', '12')
|
||||
}
|
||||
}
|
||||
'refs' {
|
||||
return @{
|
||||
Package = 'refs'
|
||||
Binary = 'refs'
|
||||
Arguments = @('helper', (Join-Path $fixtureRoot 'polyglot\repo'), '--limit', '12')
|
||||
}
|
||||
}
|
||||
'jsonshape' {
|
||||
return @{
|
||||
Package = 'jsonshape'
|
||||
Binary = 'jsonshape'
|
||||
Arguments = @((Join-Path $fixtureRoot 'toon\config.json'), '--json')
|
||||
}
|
||||
}
|
||||
default {
|
||||
return @{
|
||||
Package = $Package
|
||||
Binary = $Package
|
||||
Arguments = @('--help')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FlamegraphPackageName {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Package
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Package)) {
|
||||
throw 'Flamegraph package name must not be empty.'
|
||||
}
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($Package) -or $Package.Contains([System.IO.Path]::DirectorySeparatorChar) -or $Package.Contains([System.IO.Path]::AltDirectorySeparatorChar)) {
|
||||
throw "Flamegraph package name must be a single package token: $Package"
|
||||
}
|
||||
|
||||
if ($Package.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
|
||||
throw "Flamegraph package name contains invalid filename characters: $Package"
|
||||
}
|
||||
}
|
||||
|
||||
function Write-JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Value
|
||||
)
|
||||
|
||||
$directory = Split-Path -Parent $Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($directory)) {
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
}
|
||||
|
||||
$Value | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$cargoPath = Get-RequiredCommandPath -Name 'cargo'
|
||||
$batPath = Get-OptionalCommandPath -Name 'bat'
|
||||
$targetProfile = Resolve-ToolboxProfileName -Configuration $BuildProfile
|
||||
$flamegraphTargetProfile = Resolve-ToolboxProfileName -Configuration $FlamegraphBuildProfile
|
||||
$targetDir = Join-Path 'target' $targetProfile
|
||||
$outputRoot = Join-Path (Join-Path $workspaceRoot 'dist') 'probe'
|
||||
$profileOutputRoot = Join-Path $outputRoot $targetProfile
|
||||
$benchmarkJsonPath = Join-Path $profileOutputRoot 'hyperfine.json'
|
||||
$sizeJsonPath = Join-Path $profileOutputRoot 'binary-sizes.json'
|
||||
$bloatRoot = Join-Path $profileOutputRoot 'bloat'
|
||||
$llvmLinesRoot = Join-Path $profileOutputRoot 'llvm-lines'
|
||||
$flamegraphRoot = Join-Path $profileOutputRoot 'flamegraphs'
|
||||
|
||||
Write-Host "Mercury Toolbox probe"
|
||||
Write-Host "workspace: $workspaceRoot"
|
||||
Write-Host "profile: $BuildProfile"
|
||||
Write-Host "target dir: $targetDir"
|
||||
Write-Host "output root: $profileOutputRoot"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $profileOutputRoot | Out-Null
|
||||
if ($IncludeBuildTimings) {
|
||||
Invoke-StrictToolboxNative -FilePath $cargoPath -ArgumentList ((Get-ToolboxBuildArguments -Configuration $BuildProfile) + '--timings')
|
||||
if ($BuildProfile -ne 'Debug') {
|
||||
Invoke-StrictToolboxNative -FilePath $cargoPath -ArgumentList (
|
||||
Get-ToolboxBuildArguments -Configuration $BuildProfile -Packages (Get-ToolboxSlimBuildCommandNames)
|
||||
)
|
||||
}
|
||||
}
|
||||
else {
|
||||
Invoke-ToolboxBuild -CargoPath $cargoPath -Configuration $BuildProfile
|
||||
}
|
||||
|
||||
$binaryTable = Get-BinaryTable -WorkspaceRoot $workspaceRoot -TargetDir $targetDir
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Binary sizes:'
|
||||
$binaryTable | Format-Table -AutoSize
|
||||
Write-JsonFile -Path $sizeJsonPath -Value $binaryTable
|
||||
Write-Host "Size report: $sizeJsonPath"
|
||||
|
||||
if ($IncludeBuildTimings) {
|
||||
$timingsDir = Join-Path (Join-Path $workspaceRoot 'target') 'cargo-timings'
|
||||
$latestTiming = Get-ChildItem -LiteralPath $timingsDir -Filter 'cargo-timing*.html' -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $latestTiming) {
|
||||
Write-Host "Build timings: $($latestTiming.FullName)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipBenchmarks) {
|
||||
$hyperfinePath = Get-RequiredCommandPath -Name 'hyperfine'
|
||||
$benchmarkCommands = New-BenchmarkCommands -WorkspaceRoot $workspaceRoot -TargetDir $targetDir
|
||||
$hyperfineArguments = @(
|
||||
'--warmup',
|
||||
$Warmup.ToString(),
|
||||
'--runs',
|
||||
$Runs.ToString(),
|
||||
'--shell',
|
||||
'none',
|
||||
'--export-json',
|
||||
$benchmarkJsonPath
|
||||
) + $benchmarkCommands
|
||||
Write-Host ''
|
||||
Write-Host 'Representative latency benchmarks:'
|
||||
Invoke-StrictNative -FilePath $hyperfinePath -ArgumentList $hyperfineArguments
|
||||
Write-Host "Benchmark report: $benchmarkJsonPath"
|
||||
}
|
||||
|
||||
if ($IncludeBloat) {
|
||||
$largest = $binaryTable | Select-Object -First 3
|
||||
New-Item -ItemType Directory -Force -Path $bloatRoot | Out-Null
|
||||
|
||||
foreach ($binary in $largest) {
|
||||
$packageName = [System.IO.Path]::GetFileNameWithoutExtension([string]$binary.Name)
|
||||
$reportPath = Join-Path $bloatRoot "$packageName.txt"
|
||||
Write-Host ''
|
||||
Write-Host "cargo-bloat top crates for ${packageName}:"
|
||||
& $cargoPath @(
|
||||
'bloat',
|
||||
'--profile',
|
||||
$targetProfile,
|
||||
'-p',
|
||||
$packageName,
|
||||
'--crates',
|
||||
'-n',
|
||||
'20'
|
||||
) *> $reportPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed with exit code ${LASTEXITCODE}: cargo bloat --profile $targetProfile -p $packageName --crates -n 20"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($batPath)) {
|
||||
Get-Content -LiteralPath $reportPath -TotalCount 24
|
||||
}
|
||||
else {
|
||||
& $batPath '--style=plain' '--paging=never' $reportPath | Select-Object -First 24
|
||||
}
|
||||
Write-Host "Bloat report: $reportPath"
|
||||
}
|
||||
}
|
||||
|
||||
if ($IncludeLlvmLines) {
|
||||
$largest = $binaryTable | Select-Object -First 3
|
||||
New-Item -ItemType Directory -Force -Path $llvmLinesRoot | Out-Null
|
||||
|
||||
foreach ($binary in $largest) {
|
||||
$packageName = [System.IO.Path]::GetFileNameWithoutExtension([string]$binary.Name)
|
||||
$reportPath = Join-Path $llvmLinesRoot "$packageName.txt"
|
||||
Write-Host ''
|
||||
Write-Host "cargo-llvm-lines top items for ${packageName}:"
|
||||
& $cargoPath @(
|
||||
'llvm-lines',
|
||||
'--profile',
|
||||
$targetProfile,
|
||||
'-p',
|
||||
$packageName,
|
||||
'--bin',
|
||||
$packageName
|
||||
) *> $reportPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed with exit code ${LASTEXITCODE}: cargo llvm-lines --profile $targetProfile -p $packageName --bin $packageName"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($batPath)) {
|
||||
Get-Content -LiteralPath $reportPath -TotalCount 80
|
||||
}
|
||||
else {
|
||||
& $batPath '--style=plain' '--paging=never' $reportPath | Select-Object -First 80
|
||||
}
|
||||
Write-Host "LLVM lines report: $reportPath"
|
||||
}
|
||||
}
|
||||
|
||||
if ($IncludeFlamegraph) {
|
||||
[void](Get-RequiredCommandPath -Name 'cargo-flamegraph')
|
||||
New-Item -ItemType Directory -Force -Path $flamegraphRoot | Out-Null
|
||||
$flamegraphCapability = Get-FlamegraphCapability
|
||||
$capabilityPath = Join-Path $flamegraphRoot 'capability.json'
|
||||
Write-JsonFile -Path $capabilityPath -Value $flamegraphCapability
|
||||
Write-Host "Flamegraph capability: $capabilityPath"
|
||||
if (-not [bool]$flamegraphCapability.can_attempt) {
|
||||
Write-Warning "Skipping flamegraph collection on this machine: $($flamegraphCapability.reason)"
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace([string]$flamegraphCapability.note)) {
|
||||
Write-Warning $flamegraphCapability.note
|
||||
}
|
||||
|
||||
foreach ($package in $FlamegraphPackages) {
|
||||
Assert-FlamegraphPackageName -Package $package
|
||||
$target = Get-FlamegraphTarget -Package $package -WorkspaceRoot $workspaceRoot
|
||||
$outputPath = Join-Path $flamegraphRoot "$($target.Package).svg"
|
||||
$logPath = Join-Path $flamegraphRoot "$($target.Package).log"
|
||||
$errorPath = Join-Path $flamegraphRoot "$($target.Package)-error.txt"
|
||||
$arguments = @(
|
||||
'flamegraph',
|
||||
'--profile',
|
||||
$flamegraphTargetProfile,
|
||||
'-p',
|
||||
$target.Package,
|
||||
'--bin',
|
||||
$target.Binary,
|
||||
'-o',
|
||||
$outputPath,
|
||||
'--'
|
||||
) + $target.Arguments
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "cargo-flamegraph for $($target.Package):"
|
||||
if (-not [bool]$flamegraphCapability.can_attempt) {
|
||||
Write-JsonFile -Path $errorPath -Value ([ordered]@{
|
||||
package = $target.Package
|
||||
skipped = $true
|
||||
reason = $flamegraphCapability.reason
|
||||
profiler = $flamegraphCapability
|
||||
})
|
||||
Write-Host "Flamegraph skipped: $errorPath"
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
& $cargoPath @arguments *> $logPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed with exit code ${LASTEXITCODE}: cargo $($arguments -join ' ')"
|
||||
}
|
||||
Write-Host "Flamegraph: $outputPath"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Skipping flamegraph for $($target.Package): $($_.Exception.Message)"
|
||||
Write-JsonFile -Path $errorPath -Value ([ordered]@{
|
||||
package = $target.Package
|
||||
command = @('cargo') + $arguments
|
||||
error = $_.Exception.Message
|
||||
log_path = $logPath
|
||||
profiler = $flamegraphCapability
|
||||
})
|
||||
Write-Host "Flamegraph error log: $errorPath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$finalBinaryTable = Get-BinaryTable -WorkspaceRoot $workspaceRoot -TargetDir $targetDir
|
||||
if (($finalBinaryTable | ConvertTo-Json -Depth 4) -cne ($binaryTable | ConvertTo-Json -Depth 4)) {
|
||||
Write-Host ''
|
||||
Write-Host 'Binary sizes changed during probe; refreshing final size report:'
|
||||
$finalBinaryTable | Format-Table -AutoSize
|
||||
Write-JsonFile -Path $sizeJsonPath -Value $finalBinaryTable
|
||||
Write-Host "Refreshed size report: $sizeJsonPath"
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^v\d+\.\d+\.\d+.*$')]
|
||||
[string]$Tag,
|
||||
[string]$NotesPath,
|
||||
[string]$BaseUrl,
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
[string]$ApiToken,
|
||||
[string]$DistRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'dist')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Invoke-GitCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$output = & git @ArgumentList 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")"
|
||||
}
|
||||
|
||||
return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Resolve-GiteaRepositoryContext {
|
||||
param(
|
||||
[string]$RemoteUrl
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RemoteUrl)) {
|
||||
$RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', 'origin')
|
||||
}
|
||||
|
||||
$normalized = $RemoteUrl.Trim()
|
||||
if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = $Matches[1]
|
||||
Owner = $Matches[2]
|
||||
Repo = $Matches[3]
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
$scheme = 'https'
|
||||
$giteaHost = $Matches[1]
|
||||
$port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" }
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = "${scheme}://$giteaHost$port"
|
||||
Owner = $Matches[3]
|
||||
Repo = $Matches[4]
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not parse Gitea origin remote URL: $normalized"
|
||||
}
|
||||
|
||||
function Resolve-ApiToken {
|
||||
param(
|
||||
[string]$BaseUrl,
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
[string]$ExplicitToken
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) {
|
||||
return $ExplicitToken
|
||||
}
|
||||
|
||||
foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
return $value
|
||||
}
|
||||
}
|
||||
|
||||
$uri = [Uri]$BaseUrl
|
||||
$lines = @(
|
||||
'protocol=https'
|
||||
"host=$($uri.Authority)"
|
||||
"path=$Owner/$Repo.git"
|
||||
''
|
||||
) | git credential fill
|
||||
$passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1
|
||||
if ($null -eq $passwordLine) {
|
||||
throw 'Could not resolve a Gitea API token from the current environment or git credential manager.'
|
||||
}
|
||||
|
||||
return $passwordLine.Substring(9)
|
||||
}
|
||||
|
||||
function Invoke-GiteaApi {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Method,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Uri,
|
||||
[string]$Token,
|
||||
[object]$Body
|
||||
)
|
||||
|
||||
$headers = @{
|
||||
Authorization = "token $Token"
|
||||
}
|
||||
|
||||
$invokeArgs = @{
|
||||
Method = $Method
|
||||
Uri = $Uri
|
||||
Headers = $headers
|
||||
ErrorAction = 'Stop'
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Body')) {
|
||||
$invokeArgs.ContentType = 'application/json'
|
||||
$invokeArgs.Body = ($Body | ConvertTo-Json -Depth 10)
|
||||
}
|
||||
|
||||
try {
|
||||
return Invoke-RestMethod @invokeArgs
|
||||
}
|
||||
catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($null -eq $response) {
|
||||
throw
|
||||
}
|
||||
$statusCode = [int]$response.StatusCode
|
||||
$payload = ''
|
||||
if ($response -is [System.Net.Http.HttpResponseMessage]) {
|
||||
if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) {
|
||||
$payload = $_.ErrorDetails.Message
|
||||
}
|
||||
elseif ($null -ne $response.Content) {
|
||||
try {
|
||||
$payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
catch {
|
||||
$payload = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($null -ne $stream) {
|
||||
$reader = [System.IO.StreamReader]::new($stream)
|
||||
try {
|
||||
$payload = $reader.ReadToEnd()
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload"
|
||||
}
|
||||
}
|
||||
|
||||
function Find-ReleaseArtifacts {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RootPath)) {
|
||||
throw "dist root not found: $RootPath"
|
||||
}
|
||||
|
||||
$zip = Get-ChildItem -LiteralPath $RootPath -File -Filter 'MercuryToolbox-*-ReleaseFast.zip' |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $zip) {
|
||||
throw "No packaged archive found under $RootPath. Run just package first."
|
||||
}
|
||||
|
||||
$packageRoot = Join-Path $RootPath $zip.BaseName
|
||||
if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) {
|
||||
throw "Package directory matching archive was not found: $packageRoot"
|
||||
}
|
||||
|
||||
$hashPath = Join-Path $packageRoot 'SHA256SUMS.txt'
|
||||
$manifestPath = Join-Path $packageRoot 'mercury-toolbox-package.json'
|
||||
foreach ($requiredPath in @($hashPath, $manifestPath)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
||||
throw "Expected packaged artifact not found: $requiredPath"
|
||||
}
|
||||
}
|
||||
|
||||
return @($zip.FullName, $hashPath, $manifestPath)
|
||||
}
|
||||
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location -LiteralPath $workspaceRoot
|
||||
|
||||
try {
|
||||
$context = Resolve-GiteaRepositoryContext
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
$BaseUrl = $context.BaseUrl
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Owner)) {
|
||||
$Owner = $context.Owner
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Repo)) {
|
||||
$Repo = $context.Repo
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($NotesPath)) {
|
||||
$NotesPath = Join-Path $workspaceRoot (Join-Path 'docs\releases' "$Tag.md")
|
||||
}
|
||||
$ApiToken = Resolve-ApiToken -BaseUrl $BaseUrl -Owner $Owner -Repo $Repo -ExplicitToken $ApiToken
|
||||
if (-not (Test-Path -LiteralPath $NotesPath -PathType Leaf)) {
|
||||
throw "Release notes not found: $NotesPath"
|
||||
}
|
||||
|
||||
$notes = Get-Content -Raw -LiteralPath $NotesPath
|
||||
$releaseName = "Mercury Toolbox $Tag"
|
||||
$apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo"
|
||||
$targetCommitish = Invoke-GitCapture -ArgumentList @('rev-parse', 'HEAD')
|
||||
|
||||
$release = $null
|
||||
try {
|
||||
$release = Invoke-GiteaApi -Method 'GET' -Uri "$apiBase/releases/tags/$Tag" -Token $ApiToken
|
||||
}
|
||||
catch {
|
||||
if (-not $_.Exception.Message.Contains('HTTP 404')) {
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $release) {
|
||||
$release = Invoke-GiteaApi -Method 'POST' -Uri "$apiBase/releases" -Token $ApiToken -Body @{
|
||||
tag_name = $Tag
|
||||
target_commitish = $targetCommitish
|
||||
name = $releaseName
|
||||
body = $notes
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
}
|
||||
}
|
||||
else {
|
||||
$release = Invoke-GiteaApi -Method 'PATCH' -Uri "$apiBase/releases/$($release.id)" -Token $ApiToken -Body @{
|
||||
tag_name = $Tag
|
||||
target_commitish = $targetCommitish
|
||||
name = $releaseName
|
||||
body = $notes
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
}
|
||||
}
|
||||
|
||||
$assets = @()
|
||||
if ($release.PSObject.Properties.Name -contains 'assets' -and $null -ne $release.assets) {
|
||||
$assets = @($release.assets)
|
||||
}
|
||||
|
||||
foreach ($assetPath in (Find-ReleaseArtifacts -RootPath $DistRoot)) {
|
||||
$assetName = [System.IO.Path]::GetFileName($assetPath)
|
||||
$existing = $assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
|
||||
if ($null -ne $existing) {
|
||||
Invoke-GiteaApi -Method 'DELETE' -Uri "$apiBase/releases/$($release.id)/assets/$($existing.id)" -Token $ApiToken | Out-Null
|
||||
}
|
||||
|
||||
$uploadHeaders = @{
|
||||
Authorization = "token $ApiToken"
|
||||
}
|
||||
$uploadUri = "$apiBase/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))"
|
||||
Invoke-RestMethod -Method 'POST' -Uri $uploadUri -Headers $uploadHeaders -Form @{
|
||||
attachment = Get-Item -LiteralPath $assetPath
|
||||
} -ErrorAction Stop | Out-Null
|
||||
Write-Host "Uploaded release asset: $assetName"
|
||||
}
|
||||
|
||||
Write-Host "Release published: $($release.html_url)"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$InstanceUrl,
|
||||
[string]$Owner,
|
||||
[string]$Repo,
|
||||
[string]$RunnerName,
|
||||
[string]$RunnerRoot,
|
||||
[string]$ApiToken,
|
||||
[switch]$SkipInstall,
|
||||
[switch]$SkipStartupRegistration
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Invoke-GitCapture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$output = & git @ArgumentList 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")"
|
||||
}
|
||||
|
||||
return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Resolve-GiteaRepositoryContext {
|
||||
$remoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', 'origin')
|
||||
$normalized = $remoteUrl.Trim()
|
||||
|
||||
if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = $Matches[1]
|
||||
Owner = $Matches[2]
|
||||
Repo = $Matches[3]
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') {
|
||||
$giteaHost = $Matches[1]
|
||||
$port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" }
|
||||
return [pscustomobject]@{
|
||||
BaseUrl = "https://$giteaHost$port"
|
||||
Owner = $Matches[3]
|
||||
Repo = $Matches[4]
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not parse Gitea origin remote URL: $normalized"
|
||||
}
|
||||
|
||||
function Resolve-ApiToken {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaseUrl,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepoOwner,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepoName,
|
||||
[string]$ExplicitToken
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) {
|
||||
return $ExplicitToken
|
||||
}
|
||||
|
||||
foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
return $value
|
||||
}
|
||||
}
|
||||
|
||||
$uri = [Uri]$BaseUrl
|
||||
$lines = @(
|
||||
'protocol=https'
|
||||
"host=$($uri.Authority)"
|
||||
"path=$RepoOwner/$RepoName.git"
|
||||
''
|
||||
) | git credential fill
|
||||
$passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1
|
||||
if ($null -eq $passwordLine) {
|
||||
throw 'Could not resolve a Gitea API token from the current environment or git credential manager.'
|
||||
}
|
||||
|
||||
return $passwordLine.Substring(9)
|
||||
}
|
||||
|
||||
function Invoke-GiteaApi {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Method,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Uri,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Token
|
||||
)
|
||||
|
||||
$headers = @{
|
||||
Authorization = "token $Token"
|
||||
}
|
||||
|
||||
try {
|
||||
return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $headers -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($null -eq $response) {
|
||||
throw
|
||||
}
|
||||
$statusCode = [int]$response.StatusCode
|
||||
$payload = ''
|
||||
if ($response -is [System.Net.Http.HttpResponseMessage]) {
|
||||
if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) {
|
||||
$payload = $_.ErrorDetails.Message
|
||||
}
|
||||
elseif ($null -ne $response.Content) {
|
||||
try {
|
||||
$payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
catch {
|
||||
$payload = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($null -ne $stream) {
|
||||
$reader = [System.IO.StreamReader]::new($stream)
|
||||
try {
|
||||
$payload = $reader.ReadToEnd()
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload"
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RunnerExe {
|
||||
$command = Get-Command -Name 'gitea-runner.exe' -ErrorAction SilentlyContinue
|
||||
if ($null -ne $command) {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
$command = Get-Command -Name 'gitea-runner' -ErrorAction SilentlyContinue
|
||||
if ($null -ne $command) {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
$candidate = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Gitea.Runner_Microsoft.Winget.Source_8wekyb3d8bbwe\gitea-runner.exe'
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
return $candidate
|
||||
}
|
||||
|
||||
if ($SkipInstall) {
|
||||
throw 'gitea-runner was not found and -SkipInstall was requested.'
|
||||
}
|
||||
|
||||
& winget install --id Gitea.Runner -e --accept-package-agreements --accept-source-agreements
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "winget install Gitea.Runner failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) {
|
||||
throw "gitea-runner was installed but not found at expected path: $candidate"
|
||||
}
|
||||
|
||||
return $candidate
|
||||
}
|
||||
|
||||
$context = Resolve-GiteaRepositoryContext
|
||||
if ([string]::IsNullOrWhiteSpace($InstanceUrl)) {
|
||||
$InstanceUrl = $context.BaseUrl
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Owner)) {
|
||||
$Owner = $context.Owner
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Repo)) {
|
||||
$Repo = $context.Repo
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($RunnerName)) {
|
||||
$RunnerName = "$Repo-local-win"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($RunnerRoot)) {
|
||||
$RunnerRoot = Join-Path $env:LOCALAPPDATA "GiteaRunner\$Repo"
|
||||
}
|
||||
|
||||
$runnerExe = Resolve-RunnerExe
|
||||
$ApiToken = Resolve-ApiToken -BaseUrl $InstanceUrl -RepoOwner $Owner -RepoName $Repo -ExplicitToken $ApiToken
|
||||
$registration = Invoke-GiteaApi -Method 'POST' -Uri "$InstanceUrl/api/v1/repos/$Owner/$Repo/actions/runners/registration-token" -Token $ApiToken
|
||||
$registrationToken = [string]$registration.token
|
||||
if ([string]::IsNullOrWhiteSpace($registrationToken)) {
|
||||
throw 'Gitea did not return a runner registration token.'
|
||||
}
|
||||
|
||||
$configPath = Join-Path $RunnerRoot 'config.yaml'
|
||||
$registrationPath = Join-Path $RunnerRoot '.runner'
|
||||
$logPath = Join-Path $RunnerRoot 'daemon.log'
|
||||
$startScriptPath = Join-Path $RunnerRoot 'start-runner.ps1'
|
||||
$startupFolder = [Environment]::GetFolderPath('Startup')
|
||||
$startupCmdPath = Join-Path $startupFolder "$Repo-gitea-runner.cmd"
|
||||
$workdirParent = Join-Path $RunnerRoot 'workdir'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $RunnerRoot, $workdirParent | Out-Null
|
||||
|
||||
$fullPath = [string]::Join(';', (@(
|
||||
[Environment]::GetEnvironmentVariable('Path', 'User'),
|
||||
[Environment]::GetEnvironmentVariable('Path', 'Machine')
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }))
|
||||
$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $HOME '.cargo' }
|
||||
$rustupHome = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $HOME '.rustup' }
|
||||
$repoHostLabel = "${Repo}:host"
|
||||
|
||||
$config = @"
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 1
|
||||
envs:
|
||||
PATH: $fullPath
|
||||
CARGO_HOME: $cargoHome
|
||||
RUSTUP_HOME: $rustupHome
|
||||
CARGO_INCREMENTAL: '0'
|
||||
env_file: .env
|
||||
timeout: 3h
|
||||
shutdown_timeout: 30s
|
||||
insecure: false
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
fetch_interval_max: 5s
|
||||
workdir_cleanup_age: 24h
|
||||
idle_cleanup_interval: 10m
|
||||
labels:
|
||||
- 'windows-amd64:host'
|
||||
- 'windows:host'
|
||||
- '$repoHostLabel'
|
||||
allocate_pty: false
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
dir: ''
|
||||
host: ''
|
||||
port: 0
|
||||
external_server: ''
|
||||
external_secret: ''
|
||||
offline_mode: false
|
||||
|
||||
container:
|
||||
network: ''
|
||||
privileged: false
|
||||
options:
|
||||
workdir_parent:
|
||||
valid_volumes: []
|
||||
docker_host: ''
|
||||
force_pull: true
|
||||
force_rebuild: false
|
||||
require_docker: false
|
||||
docker_timeout: 0s
|
||||
bind_workdir: false
|
||||
|
||||
host:
|
||||
workdir_parent: '$($workdirParent -replace '\\', '/')'
|
||||
|
||||
metrics:
|
||||
enabled: false
|
||||
addr: '127.0.0.1:9101'
|
||||
"@
|
||||
Set-Content -LiteralPath $configPath -Value $config -Encoding utf8NoBOM
|
||||
|
||||
if (-not (Test-Path -LiteralPath $registrationPath)) {
|
||||
Push-Location -LiteralPath $RunnerRoot
|
||||
try {
|
||||
& $runnerExe register --no-interactive --config $configPath --instance $InstanceUrl --token $registrationToken --name $RunnerName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "gitea-runner register failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$startScript = @"
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
Set-Location '$RunnerRoot'
|
||||
& '$runnerExe' daemon --config '$configPath' *>> '$logPath'
|
||||
"@
|
||||
Set-Content -LiteralPath $startScriptPath -Value $startScript -Encoding utf8NoBOM
|
||||
|
||||
if (-not $SkipStartupRegistration) {
|
||||
$startupCmd = @"
|
||||
@echo off
|
||||
powershell -NoProfile -WindowStyle Hidden -File "$startScriptPath"
|
||||
"@
|
||||
Set-Content -LiteralPath $startupCmdPath -Value $startupCmd -Encoding ascii
|
||||
}
|
||||
|
||||
$existing = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'gitea-runner.exe' -and $_.CommandLine -like "*$configPath*" }
|
||||
if ($null -eq $existing) {
|
||||
Start-Process -FilePath $runnerExe -ArgumentList @('daemon', '--config', $configPath) -WorkingDirectory $RunnerRoot -WindowStyle Hidden | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
$runners = Invoke-GiteaApi -Method 'GET' -Uri "$InstanceUrl/api/v1/repos/$Owner/$Repo/actions/runners" -Token $ApiToken
|
||||
$matched = @($runners.runners | Where-Object { $_.name -eq $RunnerName })
|
||||
if ($matched.Count -eq 0) {
|
||||
throw "Runner $RunnerName was not visible in the repository runner list after setup."
|
||||
}
|
||||
|
||||
$matched | ConvertTo-Json -Depth 6
|
||||
@@ -0,0 +1,57 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'third_party\json-family'),
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$sources = @(
|
||||
@{
|
||||
Name = 'toon'
|
||||
Url = 'https://github.com/toon-format/toon.git'
|
||||
},
|
||||
@{
|
||||
Name = 'ison'
|
||||
Url = 'https://github.com/ISON-format/ison.git'
|
||||
},
|
||||
@{
|
||||
Name = 'zon'
|
||||
Url = 'https://github.com/ZON-Format/zon-TS.git'
|
||||
},
|
||||
@{
|
||||
Name = 'tonl'
|
||||
Url = 'https://github.com/tonl-dev/tonl.git'
|
||||
}
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null
|
||||
|
||||
foreach ($source in $sources) {
|
||||
$target = Join-Path $OutputRoot $source.Name
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
if ($Force) {
|
||||
Remove-Item -LiteralPath $target -Recurse -Force
|
||||
}
|
||||
else {
|
||||
git -C $target fetch --all --tags --prune
|
||||
git -C $target pull --ff-only
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to update $($source.Name) specs in $target"
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
git clone --depth 1 $source.Url $target
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to clone $($source.Name) specs from $($source.Url)"
|
||||
}
|
||||
}
|
||||
|
||||
@{
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString('o')
|
||||
purpose = 'Spec, fixture, and golden-test source snapshots only; not runtime dependencies.'
|
||||
sources = $sources
|
||||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputRoot 'manifest.json') -Encoding utf8NoBOM
|
||||
@@ -0,0 +1,180 @@
|
||||
function Get-ToolboxCommandNames {
|
||||
return @(
|
||||
'cjson',
|
||||
'ison',
|
||||
'isonl',
|
||||
'zon',
|
||||
'tonl',
|
||||
'mhash',
|
||||
'jsonlgrep',
|
||||
'jsonshape',
|
||||
'recent',
|
||||
'pathshadow',
|
||||
'portping',
|
||||
'portunlock',
|
||||
'msudo',
|
||||
'asmtype',
|
||||
'asmmember',
|
||||
'asmref',
|
||||
'asmapi',
|
||||
'asmflow',
|
||||
'llvmobjdump',
|
||||
'llvmreadobj',
|
||||
'llvmnm',
|
||||
'peexports',
|
||||
'peimports',
|
||||
'pecalls',
|
||||
'pesig',
|
||||
'pestrrefs',
|
||||
'drvshape',
|
||||
'ioctlscan',
|
||||
'binmeta',
|
||||
'fileprobe',
|
||||
'outline',
|
||||
'codeshape',
|
||||
'refs',
|
||||
'snip',
|
||||
'defsnip',
|
||||
'ctxpack',
|
||||
'chunkcat',
|
||||
'hitsnip',
|
||||
'diagpick',
|
||||
'logshape',
|
||||
'stringscan',
|
||||
'toon',
|
||||
'csvshape',
|
||||
'sqliteshape',
|
||||
'sqlshape',
|
||||
'unityasset',
|
||||
'unityprobe',
|
||||
'unitydiag',
|
||||
'envdiff',
|
||||
'proctree',
|
||||
'sysshape',
|
||||
'runprobe',
|
||||
'await',
|
||||
'argv',
|
||||
'config',
|
||||
'gitshape',
|
||||
'reposhape',
|
||||
'dotnetshape',
|
||||
'unlock'
|
||||
)
|
||||
}
|
||||
|
||||
function Get-ToolboxSlimBuildCommandNames {
|
||||
return @(
|
||||
'codeshape',
|
||||
'refs',
|
||||
'defsnip'
|
||||
)
|
||||
}
|
||||
|
||||
function Resolve-ToolboxProfileName {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Configuration
|
||||
)
|
||||
|
||||
switch ($Configuration) {
|
||||
'Debug' { return 'debug' }
|
||||
'Release' { return 'release' }
|
||||
'ReleaseFast' { return 'release-fast' }
|
||||
'ReleaseSize' { return 'release-size' }
|
||||
default { throw "Unsupported configuration: $Configuration" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ToolboxBuildArguments {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Configuration,
|
||||
[string[]]$Packages = @()
|
||||
)
|
||||
|
||||
$arguments = @('build')
|
||||
if ($Packages.Count -gt 0) {
|
||||
foreach ($package in $Packages) {
|
||||
$arguments += @('-p', $package)
|
||||
}
|
||||
}
|
||||
else {
|
||||
$arguments += '--workspace'
|
||||
}
|
||||
|
||||
switch ($Configuration) {
|
||||
'Release' {
|
||||
$arguments += '--release'
|
||||
}
|
||||
'ReleaseFast' {
|
||||
$arguments += @('--profile', 'release-fast')
|
||||
}
|
||||
'ReleaseSize' {
|
||||
$arguments += @('--profile', 'release-size')
|
||||
}
|
||||
}
|
||||
|
||||
return $arguments
|
||||
}
|
||||
|
||||
function Format-ToolboxNativeCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
$parts = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($part in @($FilePath) + $ArgumentList) {
|
||||
if ([string]::IsNullOrEmpty($part)) {
|
||||
$parts.Add("''")
|
||||
continue
|
||||
}
|
||||
|
||||
if ($part.IndexOfAny([char[]]" `t`r`n'`"") -ge 0) {
|
||||
$parts.Add("'" + $part.Replace("'", "''") + "'")
|
||||
continue
|
||||
}
|
||||
|
||||
$parts.Add($part)
|
||||
}
|
||||
|
||||
return ($parts -join ' ')
|
||||
}
|
||||
|
||||
function Invoke-StrictToolboxNative {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
& $FilePath @ArgumentList
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$commandLine = Format-ToolboxNativeCommand -FilePath $FilePath -ArgumentList $ArgumentList
|
||||
throw "Command failed with exit code ${LASTEXITCODE} in '$((Get-Location).Path)': $commandLine"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ToolboxBuild {
|
||||
param(
|
||||
[string]$CargoPath = 'cargo',
|
||||
[ValidateSet('Debug', 'Release', 'ReleaseFast', 'ReleaseSize')]
|
||||
[string]$Configuration = 'ReleaseFast',
|
||||
[switch]$SkipSlimBinaryRebuild
|
||||
)
|
||||
|
||||
Invoke-StrictToolboxNative -FilePath $CargoPath -ArgumentList (Get-ToolboxBuildArguments -Configuration $Configuration)
|
||||
|
||||
if ($SkipSlimBinaryRebuild -or $Configuration -eq 'Debug') {
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($commandName in Get-ToolboxSlimBuildCommandNames) {
|
||||
Invoke-StrictToolboxNative -FilePath $CargoPath -ArgumentList (
|
||||
Get-ToolboxBuildArguments -Configuration $Configuration -Packages @($commandName)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'),
|
||||
[string]$CodexHome
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot 'toolbox-commands.ps1')
|
||||
|
||||
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 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 Test-CodexSkillOwnedByMercury {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SkillRoot
|
||||
)
|
||||
|
||||
$markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json'
|
||||
if (-not (Test-Path -LiteralPath $markerPath)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json
|
||||
return [string]$marker.owner -eq 'mercury-toolbox'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
$legacyBinDir = Join-Path $InstallRoot 'bin'
|
||||
$currentRoot = Join-Path $InstallRoot 'current'
|
||||
$activeBinDir = Join-Path $currentRoot 'bin'
|
||||
$versionsRoot = Join-Path $InstallRoot 'versions'
|
||||
$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox'
|
||||
$codexSkillRoot = Join-Path (Resolve-CodexHomePath -ExplicitCodexHome $CodexHome) '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-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root'
|
||||
Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root'
|
||||
Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root'
|
||||
|
||||
if (Test-Path -LiteralPath $legacyBinDir) {
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
$commandPath = Join-Path $legacyBinDir "$commandName.exe"
|
||||
if (Test-Path -LiteralPath $commandPath) {
|
||||
Remove-Item -LiteralPath $commandPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($legacyName in $legacyCommandNames) {
|
||||
$legacyPath = Join-Path $legacyBinDir "$legacyName.exe"
|
||||
if (Test-Path -LiteralPath $legacyPath) {
|
||||
Remove-Item -LiteralPath $legacyPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
$remainingBinItems = @(Get-ChildItem -LiteralPath $legacyBinDir -Force)
|
||||
if ($remainingBinItems.Count -eq 0) {
|
||||
Remove-Item -LiteralPath $legacyBinDir -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $currentRoot) {
|
||||
Remove-Item -LiteralPath $currentRoot -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $versionsRoot) {
|
||||
Remove-Item -LiteralPath $versionsRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $shareRoot) {
|
||||
Remove-Item -LiteralPath $shareRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $codexSkillRoot) {
|
||||
if (Test-CodexSkillOwnedByMercury -SkillRoot $codexSkillRoot) {
|
||||
Remove-Item -LiteralPath $codexSkillRoot -Recurse -Force
|
||||
}
|
||||
else {
|
||||
Write-Warning "Skipping Codex skill removal because Mercury Toolbox does not own this skill copy: $codexSkillRoot"
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $InstallRoot) {
|
||||
$remainingItems = @(Get-ChildItem -LiteralPath $InstallRoot -Force)
|
||||
if ($remainingItems.Count -eq 0) {
|
||||
Remove-Item -LiteralPath $InstallRoot -Force
|
||||
}
|
||||
}
|
||||
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
foreach ($entry in @($legacyBinDir, $activeBinDir)) {
|
||||
$pathUpdate = Remove-PathEntry -ExistingPath $userPath -Entry $entry
|
||||
if ($pathUpdate.Changed) {
|
||||
$userPath = $pathUpdate.Value
|
||||
}
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('Path', $userPath, 'User')
|
||||
|
||||
foreach ($entry in @($legacyBinDir, $activeBinDir)) {
|
||||
$sessionUpdate = Remove-PathEntry -ExistingPath $env:Path -Entry $entry
|
||||
if ($sessionUpdate.Changed) {
|
||||
$env:Path = $sessionUpdate.Value
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Mercury Toolbox package removed from $InstallRoot"
|
||||
Write-Host "Removed Codex skill root: $codexSkillRoot"
|
||||
@@ -0,0 +1,221 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'MercuryToolbox'),
|
||||
[string]$CodexHome
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot 'toolbox-commands.ps1')
|
||||
|
||||
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 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 Test-CodexSkillOwnedByMercury {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SkillRoot
|
||||
)
|
||||
|
||||
$markerPath = Join-Path $SkillRoot '.mercury-toolbox-owner.json'
|
||||
if (-not (Test-Path -LiteralPath $markerPath)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json
|
||||
return [string]$marker.owner -eq 'mercury-toolbox'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
$legacyBinDir = Join-Path $InstallRoot 'bin'
|
||||
$currentRoot = Join-Path $InstallRoot 'current'
|
||||
$activeBinDir = Join-Path $currentRoot 'bin'
|
||||
$versionsRoot = Join-Path $InstallRoot 'versions'
|
||||
$shareRoot = Join-Path $InstallRoot 'share\mercury-toolbox'
|
||||
$codexSkillRoot = Join-Path (Resolve-CodexHomePath -ExplicitCodexHome $CodexHome) '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-ExistingPathNotReparsePoint -Path $codexSkillRoot -Description 'Codex skill root'
|
||||
Assert-ExistingTreeHasNoReparsePoints -Path $shareRoot -Description 'share root'
|
||||
Assert-ExistingTreeHasNoReparsePoints -Path $codexSkillRoot -Description 'Codex skill root'
|
||||
|
||||
if (Test-Path -LiteralPath $legacyBinDir) {
|
||||
foreach ($commandName in Get-ToolboxCommandNames) {
|
||||
$commandPath = Join-Path $legacyBinDir "$commandName.exe"
|
||||
if (Test-Path -LiteralPath $commandPath) {
|
||||
Remove-Item -LiteralPath $commandPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($legacyName in $legacyCommandNames) {
|
||||
$legacyPath = Join-Path $legacyBinDir "$legacyName.exe"
|
||||
if (Test-Path -LiteralPath $legacyPath) {
|
||||
Remove-Item -LiteralPath $legacyPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
$remainingBinItems = @(Get-ChildItem -LiteralPath $legacyBinDir -Force)
|
||||
if ($remainingBinItems.Count -eq 0) {
|
||||
Remove-Item -LiteralPath $legacyBinDir -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $currentRoot) {
|
||||
Remove-Item -LiteralPath $currentRoot -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $versionsRoot) {
|
||||
Remove-Item -LiteralPath $versionsRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $shareRoot) {
|
||||
Remove-Item -LiteralPath $shareRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $codexSkillRoot) {
|
||||
if (Test-CodexSkillOwnedByMercury -SkillRoot $codexSkillRoot) {
|
||||
Remove-Item -LiteralPath $codexSkillRoot -Recurse -Force
|
||||
}
|
||||
else {
|
||||
Write-Warning "Skipping Codex skill removal because Mercury Toolbox does not own this skill copy: $codexSkillRoot"
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $InstallRoot) {
|
||||
$remainingItems = @(Get-ChildItem -LiteralPath $InstallRoot -Force)
|
||||
if ($remainingItems.Count -eq 0) {
|
||||
Remove-Item -LiteralPath $InstallRoot -Force
|
||||
}
|
||||
}
|
||||
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
foreach ($entry in @($legacyBinDir, $activeBinDir)) {
|
||||
$pathUpdate = Remove-PathEntry -ExistingPath $userPath -Entry $entry
|
||||
if ($pathUpdate.Changed) {
|
||||
$userPath = $pathUpdate.Value
|
||||
}
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('Path', $userPath, 'User')
|
||||
|
||||
foreach ($entry in @($legacyBinDir, $activeBinDir)) {
|
||||
$sessionUpdate = Remove-PathEntry -ExistingPath $env:Path -Entry $entry
|
||||
if ($sessionUpdate.Changed) {
|
||||
$env:Path = $sessionUpdate.Value
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Mercury Toolbox removed from $InstallRoot"
|
||||
Write-Host "Removed Codex skill root: $codexSkillRoot"
|
||||
Reference in New Issue
Block a user