| 1 | # Archive installation only. Runtime authority remains with Codewhale Core. |
| 2 | $ErrorActionPreference = 'Stop' |
| 3 | $destination = Join-Path $env:USERPROFILE 'bin' |
| 4 | $names = @('codewhale.exe', 'codew.exe', 'codewhale.bat') |
| 5 | $optional = $env:CODEWHALE_INSTALL_COMPILED_HOST -eq '1' |
| 6 | if ($optional) { |
| 7 | $names += @('codewhale-extension-host.exe', 'codewhale-extension-host.LICENSES.txt', 'codewhale-extension-host.relink-source.tar.gz', 'codewhale-extension-host.release.json') |
| 8 | } |
| 9 | function File-Hash([string]$file) { |
| 10 | if (!(Test-Path -LiteralPath $file)) { return $null } |
| 11 | $item = Get-Item -LiteralPath $file -Force |
| 12 | if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Refusing nonregular installation file $file" } |
| 13 | return (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() |
| 14 | } |
| 15 | function Host-Entry([string]$receipt) { |
| 16 | if ((Get-Item -LiteralPath $receipt).Length -gt 65536) { throw 'Compiled-host receipt exceeds 64 KiB' } |
| 17 | $catalog = Get-Content -LiteralPath $receipt -Raw | ConvertFrom-Json |
| 18 | $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() |
| 19 | if ($architecture -eq 'x64') { $target = 'windows-x64' } |
| 20 | elseif ($architecture -eq 'arm64') { $target = 'windows-arm64' } |
| 21 | else { throw "No qualified compiled host for Windows/$architecture; use Node" } |
| 22 | $rows = @($catalog.hosts | Where-Object { $_.target -eq $target }) |
| 23 | if ($catalog.schema -ne 1 -or $rows.Count -ne 1) { throw 'No unique qualified compiled-host receipt' } |
| 24 | $hostEntry = $rows[0] |
| 25 | if ($hostEntry.license_closure -ne 'complete' -or $hostEntry.relink_source -ne 'complete' -or $hostEntry.native_platform -ne 'win32' -or $hostEntry.native_arch -ne $architecture -or $hostEntry.passed -ne 5 -or $hostEntry.failed -ne 0 -or $hostEntry.skipped -ne 0 -or $hostEntry.native_passed -lt 9 -or $hostEntry.native_failed -ne 0 -or $hostEntry.native_skipped -ne 0) { throw 'Compiled host has no complete matching-native qualification' } |
| 26 | return $hostEntry |
| 27 | } |
| 28 | $stage = $null |
| 29 | $published = @() |
| 30 | $retain = $false |
| 31 | try { |
| 32 | # Check every source before the first destination mutation. |
| 33 | foreach ($name in $names) { |
| 34 | if (!(File-Hash (Join-Path $PSScriptRoot $name))) { throw "Missing archive payload $name; extract the complete archive" } |
| 35 | } |
| 36 | if ((File-Hash (Join-Path $PSScriptRoot 'codewhale.exe')) -ne (File-Hash (Join-Path $PSScriptRoot 'codew.exe'))) { throw 'Command alias differs from the canonical Codewhale program' } |
| 37 | if ($optional) { |
| 38 | $entry = Host-Entry (Join-Path $PSScriptRoot 'codewhale-extension-host.release.json') |
| 39 | $hashes = @($entry.sha256, $entry.notices_sha256, $entry.source_sha256) |
| 40 | for ($i = 0; $i -lt 3; $i++) { |
| 41 | if ($hashes[$i] -notmatch '^[a-f0-9]{64}$' -or (File-Hash (Join-Path $PSScriptRoot $names[$i + 3])) -ne $hashes[$i]) { throw 'Compiled host archive payload hash mismatch' } |
| 42 | } |
| 43 | } |
| 44 | if (Test-Path -LiteralPath $destination) { |
| 45 | $directory = Get-Item -LiteralPath $destination -Force |
| 46 | if (!$directory.PSIsContainer -or ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing a nonregular or redirected installation directory' } |
| 47 | } |
| 48 | [IO.Directory]::CreateDirectory($destination) | Out-Null |
| 49 | $stage = Join-Path $destination ('.codewhale-install-' + [Guid]::NewGuid().ToString('N')) |
| 50 | [IO.Directory]::CreateDirectory($stage) | Out-Null |
| 51 | $oldHost = $null |
| 52 | if ($optional -and (File-Hash (Join-Path $destination 'codewhale-extension-host.release.json'))) { $oldHost = Host-Entry (Join-Path $destination 'codewhale-extension-host.release.json') } |
| 53 | $snapshots = @() |
| 54 | foreach ($name in $names) { |
| 55 | $target = Join-Path $destination $name |
| 56 | $oldHash = File-Hash $target |
| 57 | if ($optional -and $name -like 'codewhale-extension-host*' -and $name -ne 'codewhale-extension-host.release.json' -and $oldHash) { |
| 58 | if (!$oldHost) { throw "Refusing unclaimed compiled-host destination $target" } |
| 59 | $expected = switch ($name) { 'codewhale-extension-host.exe' { $oldHost.sha256 }; 'codewhale-extension-host.LICENSES.txt' { $oldHost.notices_sha256 }; 'codewhale-extension-host.relink-source.tar.gz' { $oldHost.source_sha256 } } |
| 60 | if ($oldHash -ne $expected) { throw "Refusing modified compiled-host destination $target" } |
| 61 | } |
| 62 | $staged = Join-Path $stage $name |
| 63 | Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $staged |
| 64 | $newHash = File-Hash $staged |
| 65 | if ($newHash -ne (File-Hash (Join-Path $PSScriptRoot $name))) { throw "Source changed during staging: $name" } |
| 66 | $backup = Join-Path $stage ($name + '.previous') |
| 67 | if ($oldHash) { |
| 68 | Copy-Item -LiteralPath $target -Destination $backup |
| 69 | if ((File-Hash $backup) -ne $oldHash) { throw "Destination changed during backup: $target" } |
| 70 | } |
| 71 | $snapshots += [PSCustomObject]@{ Name=$name; Target=$target; Staged=$staged; OldHash=$oldHash; NewHash=$newHash; Backup=$backup } |
| 72 | } |
| 73 | foreach ($row in $snapshots) { if ((File-Hash $row.Target) -ne $row.OldHash) { throw "Destination changed before publication: $($row.Target)" } } |
| 74 | foreach ($row in $snapshots) { |
| 75 | if ((File-Hash $row.Target) -ne $row.OldHash) { throw "Destination changed during publication: $($row.Target)" } |
| 76 | Move-Item -LiteralPath $row.Staged -Destination $row.Target -Force:([bool]$row.OldHash) |
| 77 | $published += $row |
| 78 | } |
| 79 | Write-Host "Installed canonical Codewhale commands to $destination. Add this directory to your user PATH. Node remains the default host." |
| 80 | } catch { |
| 81 | $original = $_ |
| 82 | [array]::Reverse($published) |
| 83 | foreach ($row in $published) { |
| 84 | try { |
| 85 | if ((File-Hash $row.Target) -ne $row.NewHash) { throw "Published file changed; preserved $($row.Target)" } |
| 86 | if ($row.OldHash) { Move-Item -LiteralPath $row.Backup -Destination $row.Target -Force } |
| 87 | else { Remove-Item -LiteralPath $row.Target } |
| 88 | } catch { $retain = $true; Write-Warning $_ } |
| 89 | } |
| 90 | if ($retain) { Write-Warning "Rollback incomplete; recovery files retained at $stage" } |
| 91 | Write-Error $original -ErrorAction Continue |
| 92 | exit 1 |
| 93 | } finally { |
| 94 | if ($stage -and !$retain) { Remove-Item -LiteralPath $stage -Recurse -Force } |
| 95 | } |
| 96 |