[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 ', ' reposhape . --json | ConvertFrom-Json', ' peimports --json | ConvertFrom-Json', ' drvshape --json | ConvertFrom-Json', ' asmref diagnose --resolve-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"