qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Assert-RegularSource {
    param([string] $Path)
    $item = Get-Item -LiteralPath $Path
    if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
        throw 'Release inputs must be regular files.'
    }
}

function New-ReleasePackage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$')][string] $Version,
        [Parameter(Mandatory)][string] $Executable,
        [Parameter(Mandatory)][string] $Readme,
        [Parameter(Mandatory)][string] $LicenseRoot,
        [Parameter(Mandatory)][string] $SkillRoot,
        [Parameter(Mandatory)][string] $OutputDirectory
    )
    Assert-RegularSource $Executable
    Assert-RegularSource $Readme
    $pe = [IO.File]::ReadAllBytes($Executable)
    if ($pe.Length -lt 64 -or $pe[0] -ne 0x4d -or $pe[1] -ne 0x5a) { throw 'Invalid Windows executable.' }
    $offset = [BitConverter]::ToUInt32($pe, 0x3c)
    if ($offset -gt $pe.Length - 6 -or [BitConverter]::ToUInt32($pe, $offset) -ne 0x4550 -or
        [BitConverter]::ToUInt16($pe, $offset + 4) -ne 0x8664) { throw 'Expected a Windows x64 PE executable.' }
    $skill = Get-Item -LiteralPath $SkillRoot
    if (-not $skill.PSIsContainer -or ($skill.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
        throw 'Skill root must be a regular directory.'
    }
    Assert-RegularSource (Join-Path $SkillRoot 'SKILL.md')
    $sources = [ordered]@{ 'qq-cli.exe' = $Executable; 'README.md' = $Readme }
    foreach ($license in @('LICENSE-MIT', 'LICENSE-APACHE')) {
        $path = Join-Path $LicenseRoot $license
        Assert-RegularSource $path
        $sources[$license] = $path
    }
    foreach ($entry in Get-ChildItem -LiteralPath $SkillRoot -Recurse | Sort-Object FullName) {
        if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Skill links are not release inputs.' }
        if ($entry.PSIsContainer) { continue }
        Assert-RegularSource $entry.FullName
        $relative = [IO.Path]::GetRelativePath($skill.FullName, $entry.FullName).Replace('\', '/')
        $sources["skills/qq-cli/$relative"] = $entry.FullName
    }
    $root = [IO.Path]::GetFullPath($OutputDirectory)
    $null = [IO.Directory]::CreateDirectory($root)
    $archiveName = "qq-cli-v$Version-windows-x86_64.zip"
    $archivePath = Join-Path $root $archiveName
    $checksumPath = Join-Path $root 'SHA256SUMS'
    if ((Test-Path -LiteralPath $archivePath) -or (Test-Path -LiteralPath $checksumPath)) {
        throw 'Release output already exists; choose a new directory.'
    }
    $file = [IO.File]::Open($archivePath, [IO.FileMode]::CreateNew)
    try {
        $zip = [IO.Compression.ZipArchive]::new($file, [IO.Compression.ZipArchiveMode]::Create, $true)
        try {
            foreach ($name in $sources.Keys) {
                $entry = $zip.CreateEntry($name, [IO.Compression.CompressionLevel]::Optimal)
                $entry.LastWriteTime = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
                $sourceStream = [IO.File]::OpenRead($sources[$name])
                try {
                    $destinationStream = $entry.Open()
                    try { $sourceStream.CopyTo($destinationStream) } finally { $destinationStream.Dispose() }
                } finally { $sourceStream.Dispose() }
            }
        } finally { $zip.Dispose() }
    } finally { $file.Dispose() }
    $hash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
    $checksums = [IO.File]::Open($checksumPath, [IO.FileMode]::CreateNew)
    try {
        $bytes = [Text.Encoding]::ASCII.GetBytes("$hash  $archiveName`n")
        $checksums.Write($bytes, 0, $bytes.Length)
    } finally { $checksums.Dispose() }
    Test-ReleasePackage -Archive $archivePath -Checksums $checksumPath -ExpectedEntries @($sources.Keys)
    [pscustomobject]@{ Archive = $archivePath; Checksums = $checksumPath; Version = $Version; Entries = $sources.Count }
}

function Test-ReleasePackage {
    [CmdletBinding()]
    param([string] $Archive, [string] $Checksums, [string[]] $ExpectedEntries)
    $hash = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant()
    $expected = "$hash  $([IO.Path]::GetFileName($Archive))`n"
    if ([IO.File]::ReadAllText($Checksums) -cne $expected) { throw 'Release checksum does not match.' }
    $zip = [IO.Compression.ZipFile]::OpenRead($Archive)
    try {
        $names = @($zip.Entries | ForEach-Object FullName)
        if ($names.Count -ne $ExpectedEntries.Count -or
            @($names | Where-Object { $_ -cnotin $ExpectedEntries }).Count -ne 0 -or
            @($names | Sort-Object -Unique).Count -ne $names.Count) { throw 'Unexpected release archive entries.' }
        foreach ($name in $names) {
            if ($name -notin @('qq-cli.exe', 'README.md', 'LICENSE-MIT', 'LICENSE-APACHE') -and -not $name.StartsWith('skills/qq-cli/', [StringComparison]::Ordinal)) {
                throw 'Only the original CLI, README and skill belong in the release archive.'
            }
            if ($name -match '(^|/)(\.\.?|\.agents|work|runtime|target)(/|$)' -or $name.Contains('\')) {
                throw 'Unsafe release archive path.'
            }
        }
    } finally { $zip.Dispose() }
}

Export-ModuleMember -Function New-ReleasePackage, Test-ReleasePackage