chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:33:01 +08:00
commit 34d6a57f38
510 changed files with 163501 additions and 0 deletions
+138
View File
@@ -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
+294
View File
@@ -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
}
}
+74
View File
@@ -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."
}
+74
View File
@@ -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."
}
+22
View File
@@ -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
+365
View File
@@ -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
}
+321
View File
@@ -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
}
+424
View File
@@ -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
}
+131
View File
@@ -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
}
+274
View File
@@ -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)"
+47
View File
@@ -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"
+341
View File
@@ -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"
+468
View File
@@ -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"
+100
View File
@@ -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).'
}
}
+557
View File
@@ -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'
+507
View File
@@ -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'
+189
View File
@@ -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
}
+356
View File
@@ -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"
+510
View File
@@ -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"
}
+280
View File
@@ -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
}
+319
View File
@@ -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
+57
View File
@@ -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
+180
View File
@@ -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)
)
}
}
+221
View File
@@ -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"
+221
View File
@@ -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"