diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f8904f44..2c660ed5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,6 +50,30 @@ downloading one with `gh`. MSIX composition requires Visual Studio Build Tools with the Desktop development with C++ workload and the Windows SDK. Build x64 and ARM64 separately. +For the development inner loop, register a Developer Mode layout instead of +building an MSIX: + +```powershell +.\scripts\Deploy-LocalPackage.ps1 +``` + +`Deploy-LocalPackage.ps1` publishes the NativeAOT launcher, assembles a layout +under `artifacts\local-package`, registers it with `Add-AppxPackage -Register`, +and runs `clawctl setup`. It never builds, signs, or installs an MSIX, and it +requires no changes to the packaging scripts or project files. It is idempotent +and skips work when nothing changed, so keep its up-to-date check honest: the +fingerprint hashes the launcher rather than trusting timestamps, because +publish can refresh timestamps with no source change. + +Loose registration and MSIX installation are mutually exclusive for one package +identity, and Windows cannot preserve packaged app data across that switch, so +the script refuses by default and requires `-ReplaceExistingInstall`. The +registered package reads its files from the repository, so treat +`artifacts\local-package` and the checkout as live inputs, not scratch output. +Its scenario tests inject every GitHub, publish, certificate-free registration, +and deployment operation; no test may register, remove, or modify a real +package. + ## Architecture - `OpenClaw.Gateway.Launcher` is a .NET 10 NativeAOT executable packaged as diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index c0e563c3..812a1075 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -101,6 +101,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test local package deployment + shell: pwsh + run: > + .\scripts\Test-Deploy-LocalPackage.Tests.ps1 + - name: Test MSIX bundle build shell: pwsh run: > diff --git a/README.md b/README.md index 8299cfb2..a306918f 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,63 @@ an unsigned NativeAOT MSIX. local payload directory. `-NodeArchivePath` can supply an already-downloaded archive, but its version and architecture must match the payload metadata. +### Running a local development build + +To go from a clean checkout to a registered, runnable package: + +```powershell +.\scripts\Deploy-LocalPackage.ps1 +``` + +This is the development inner loop. It does not build, sign, or install an +MSIX. It acquires the payload and the bundled Node.js runtime, publishes the +NativeAOT launcher, assembles a Developer Mode layout under +`artifacts\local-package`, registers it with `Add-AppxPackage -Register`, and +runs `clawctl setup` so `openclaw` is immediately usable. + +The command is idempotent: re-running with nothing changed reports that the +package is already up to date and does nothing, and re-running after a source +or payload change rebuilds only what changed. The expanded application is +linked into the layout rather than copied, so repeat runs neither re-download +nor duplicate hundreds of megabytes. + +| Option | Behavior | +| --- | --- | +| `-RefreshPayload` | Download the payload again; the previous one is kept until the new one registers successfully | +| `-PayloadRunId ` | Use a specific successful workflow run, reusing a matching cached payload | +| `-PayloadDirectory ` | Read a prepared payload directly, with no GitHub access and no modification; pass it on every run | +| `-Architecture x64` / `arm64` | Select the architecture; it must be runnable on this device | +| `-ReplaceExistingInstall` | Remove a conflicting MSIX-installed package first (see below) | +| `-SkipSetup` | Register without extracting the Node.js runtime | +| `-Force` | Re-register even when nothing changed | +| `-Unregister` | Remove the local registration, preserving app data and caches | + +**Requires Developer Mode**, which the script checks before doing any work. + +**It cannot coexist with an MSIX-installed `OpenClaw.Gateway`.** Windows +refuses to replace a packaged install with a local layout, and it cannot +preserve that package's app data across the switch, so the script stops and +explains rather than removing anything implicitly. Pass +`-ReplaceExistingInstall` to accept that trade. + +**Run `-Unregister` before installing a released package.** Windows will not +replace a loose registration with a packaged install: `Add-AppxPackage` fails +with `0x80073CFB`, reporting that an unpackaged version is already installed +and a packaged version cannot replace it. This is the same mutual exclusion as +above, in the other direction, and it applies regardless of version. Unregister +first, then install the release: + +```powershell +.\scripts\Deploy-LocalPackage.ps1 -Unregister +Add-AppxPackage -Path .\OpenClawGateway-0.0.0.0-x64.msix +``` + +**The registered package reads its files from the repository.** Deleting +`artifacts\local-package`, moving the checkout, or deleting the worktree breaks +the registration until the command runs again; `-Unregister` first if you plan +to remove the checkout. Local builds are unsigned development artifacts and are +never official-signing inputs. + Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: diff --git a/scripts/Deploy-LocalPackage.ps1 b/scripts/Deploy-LocalPackage.ps1 new file mode 100644 index 00000000..921a4459 --- /dev/null +++ b/scripts/Deploy-LocalPackage.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS +Builds and registers a local development OpenClaw Gateway package, ready to run. +.DESCRIPTION +Takes a clean checkout to a registered, runnable OpenClaw.Gateway package without +producing, signing, or installing an MSIX. It acquires the OpenClaw payload and +the bundled Node.js runtime, publishes the NativeAOT launcher, assembles a +Developer Mode layout, and registers it with Add-AppxPackage -Register. + +The command is idempotent. Re-running with nothing changed reports that the +package is already up to date and does nothing; re-running after a source or +payload change rebuilds only what changed and re-registers. Downloads are cached +under artifacts\local-package, and the expanded application is linked rather +than copied, so repeat runs do not re-fetch or duplicate hundreds of megabytes. + +Requires Developer Mode. The registered package reads its files from the +repository, so deleting artifacts\local-package or the checkout breaks it until +the command runs again. This is a development build and is not an +official-signing input; use Build-LocalMSIX.ps1 for a verified unsigned MSIX. +.PARAMETER Architecture +Build and register x64 (default) or arm64. The selected architecture must be +runnable on this device. +.PARAMETER PayloadDirectory +Use an existing payload root containing app and payload-metadata.json. The +directory is read directly and never modified, and no GitHub access is needed. +Pass it on every run; it is not remembered. +.PARAMETER PayloadRunId +Use a specific successful workflow run instead of the latest one on main. A +matching cached payload is reused rather than downloaded again. +.PARAMETER RefreshPayload +Download the payload again instead of reusing the cache. The previous payload +is kept until the new one is registered successfully. +.PARAMETER ReplaceExistingInstall +Take over an existing install this checkout does not own: an MSIX-installed +OpenClaw.Gateway, or a local registration from another checkout. Windows cannot +replace a packaged install with a local layout and cannot preserve its app data +across that switch, so this is never done implicitly. +.PARAMETER Force +Re-register even when nothing changed. +.PARAMETER SkipSetup +Skip the final `clawctl setup` that extracts the bundled Node.js runtime. The +package is registered but not runnable until setup is run once. +.PARAMETER Unregister +Remove the local development registration and exit. Cached payloads and +runtimes are kept, and the package's app data is preserved. Run this before +installing a released package: Windows will not replace a loose registration +with a packaged install, regardless of version. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 +Clean checkout to a registered, runnable package; later runs reuse the cache. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 -RefreshPayload +Pick up a newer OpenClaw payload from the latest successful main workflow run. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 -PayloadDirectory E:\payloads\x64 +Register from a prepared payload without contacting GitHub. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 -Unregister +Remove the local registration. +#> +[CmdletBinding(DefaultParameterSetName = 'Deploy')] +param( + [ValidateSet('x64', 'arm64')] + [string]$Architecture = 'x64', + + [Parameter(ParameterSetName = 'Deploy')] + [string]$PayloadDirectory, + + [Parameter(ParameterSetName = 'Deploy')] + [long]$PayloadRunId, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$RefreshPayload, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$ReplaceExistingInstall, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$Force, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$SkipSetup, + + [Parameter(Mandatory, ParameterSetName = 'Unregister')] + [switch]$Unregister +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $PSScriptRoot 'LocalPackage.psm1') -Force + +if ($Unregister) { + Remove-LocalPackageRegistration -RepositoryRoot $repositoryRoot -Architecture $Architecture + return +} + +Invoke-LocalPackageDeployment ` + -RepositoryRoot $repositoryRoot ` + -Architecture $Architecture ` + -PayloadDirectory $PayloadDirectory ` + -PayloadRunId $PayloadRunId ` + -RefreshPayload:$RefreshPayload ` + -ReplaceExistingInstall:$ReplaceExistingInstall ` + -Force:$Force ` + -SkipSetup:$SkipSetup | + Out-Null diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 new file mode 100644 index 00000000..2b3dd5a7 --- /dev/null +++ b/scripts/LocalPackage.psm1 @@ -0,0 +1,773 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:PackageName = 'OpenClaw.Gateway' +$script:StateSchema = 1 + +function Read-LocalPackageRecord { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + try { + $record = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable + } + catch { + throw "Cannot read local state '$Path': $($_.Exception.Message)" + } + if ($record -isnot [System.Collections.IDictionary]) { + throw "Local state must be a JSON object: $Path" + } + return $record +} + +function Write-LocalPackageRecord { + param([string]$Path, [System.Collections.IDictionary]$Record) + + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + [IO.File]::WriteAllText($temporary, ($Record | ConvertTo-Json -Depth 8) + "`n") + [IO.File]::Move($temporary, $Path, $true) + } + finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } + } +} + +function Get-LocalPackageFingerprint { + param([string[]]$Parts) + + $sha = [Security.Cryptography.SHA256]::Create() + try { + return [Convert]::ToHexString( + $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($Parts -join "`n"))) + ).ToLowerInvariant() + } + finally { $sha.Dispose() } +} + +function Assert-LocalPackagePayload { + param([string]$Directory, [string]$Architecture) + + $application = Join-Path $Directory 'app' + if (-not (Test-Path -LiteralPath (Join-Path $application 'openclaw.mjs') -PathType Leaf)) { + throw "Payload is missing app\openclaw.mjs: $Directory. Supply a complete -PayloadDirectory or use -RefreshPayload." + } + $metadata = Read-LocalPackageRecord (Join-Path $Directory 'payload-metadata.json') + if ($null -eq $metadata -or $metadata['architecture'] -ne $Architecture -or + $metadata['layout'] -ne 'expanded-directory' -or + [string]$metadata['nodeVersion'] -notmatch '^v?\d+\.\d+\.\d+$') { + throw "Payload metadata must describe an expanded $Architecture directory with a Node.js version: $Directory. Supply a matching payload or use -RefreshPayload." + } + return ([string]$metadata['nodeVersion']).TrimStart('v') +} + +function Assert-LocalPackagePayloadIsSafe { + param([string]$Directory) + + # The packaged layout links to this tree, so a link inside it would resolve + # outside the package at run time. + $application = Join-Path $Directory 'app' + foreach ($entry in Get-ChildItem -LiteralPath $application -Force -Recurse) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw ( + 'The downloaded payload contains a link or reparse point: ' + + [IO.Path]::GetRelativePath($application, $entry.FullName) + ) + } + if (-not $entry.PSIsContainer -and + ($entry.Name -ieq 'node.exe' -or $entry.Name -match '^node-v\d')) { + throw ( + 'The downloaded payload must not bundle Node.js: ' + + [IO.Path]::GetRelativePath($application, $entry.FullName) + ) + } + } +} + +function Get-LocalPackageGitHubCommand { + # More than one gh.exe can be on PATH; take the first match rather than + # letting .Source concatenate every candidate path. + $gh = @(Get-Command gh -CommandType Application -ErrorAction SilentlyContinue) | + Select-Object -First 1 + if ($null -eq $gh) { + throw 'GitHub CLI (gh) is required to acquire a payload. Install and authenticate gh, or pass -PayloadDirectory; a completed cache needs no gh.' + } + return $gh +} + +function Resolve-LocalPackagePayload { + param( + [string]$CacheDirectory, + [string]$Architecture, + [string]$PayloadDirectory, + [long]$PayloadRunId, + [switch]$RefreshPayload, + [hashtable]$Operations + ) + + if ($PayloadDirectory) { + $directory = (Resolve-Path -LiteralPath $PayloadDirectory -ErrorAction Stop).Path + $nodeVersion = Assert-LocalPackagePayload $directory $Architecture + Write-Host "Payload: supplied directly ($directory)" + return [pscustomobject]@{ + Directory = $directory; NodeVersion = $nodeVersion + CacheHit = $true; Superseded = $null + PendingSelection = $null; SelectionPath = $null + } + } + + New-Item -Path $CacheDirectory -ItemType Directory -Force | Out-Null + $selectionPath = Join-Path $CacheDirectory 'current.json' + $current = $null + try { $current = Read-LocalPackageRecord $selectionPath } + catch { + if (-not $RefreshPayload) { + throw "$($_.Exception.Message) Run with -RefreshPayload to replace the invalid selection." + } + Write-Warning "Replacing an unreadable cache selection: $selectionPath" + } + if ($null -ne $current) { + if ($current['schemaVersion'] -ne $script:StateSchema -or + $current['architecture'] -ne $Architecture -or + $current['generation'] -isnot [string] -or + $current['generation'] -notmatch '^[1-9]\d*-[0-9a-f]{32}$' -or + $current['runId'] -isnot [long] -or $current['runId'] -le 0) { + if (-not $RefreshPayload) { + throw "Invalid payload cache selection: $selectionPath. Run with -RefreshPayload to replace it." + } + Write-Warning "Replacing an invalid cache selection: $selectionPath" + $current = $null + } + if ($null -ne $current -and -not $RefreshPayload -and + ($PayloadRunId -eq 0 -or $PayloadRunId -eq $current['runId'])) { + $directory = Join-Path $CacheDirectory $current['generation'] + $nodeVersion = Assert-LocalPackagePayload $directory $Architecture + Write-Host "Payload: cache hit, run $($current['runId'])" + return [pscustomobject]@{ + Directory = $directory; NodeVersion = $nodeVersion + CacheHit = $true; Superseded = $null + PendingSelection = $null; SelectionPath = $null + } + } + } + + $runId = if ($PayloadRunId -gt 0) { $PayloadRunId } else { & $Operations.LatestRun } + if ($runId -isnot [long] -or $runId -le 0) { + throw 'Payload selection did not return a valid workflow run ID.' + } + $generation = "$runId-$([guid]::NewGuid().ToString('N'))" + $directory = Join-Path $CacheDirectory $generation + New-Item -Path $directory -ItemType Directory | Out-Null + $complete = $false + try { + Write-Host "Payload: downloading run $runId" + & $Operations.Download $runId $Architecture $directory | Out-Null + $nodeVersion = Assert-LocalPackagePayload $directory $Architecture + Assert-LocalPackagePayloadIsSafe $directory + $complete = $true + } + finally { + if (-not $complete) { Remove-Item -LiteralPath $directory -Recurse -Force } + } + + # The selection is committed and the replaced generation retired by the + # caller, once the whole deployment succeeds. Committing here would leave a + # failed run selecting a payload that was never successfully deployed. + $superseded = $null + if ($null -ne $current) { + $old = Join-Path $CacheDirectory $current['generation'] + if ((Test-Path -LiteralPath $old -PathType Container) -and + (([IO.File]::GetAttributes($old) -band [IO.FileAttributes]::ReparsePoint) -eq 0)) { + $superseded = $old + } + } + return [pscustomobject]@{ + Directory = $directory; NodeVersion = $nodeVersion + CacheHit = $false; Superseded = $superseded + PendingSelection = [ordered]@{ + schemaVersion = $script:StateSchema + architecture = $Architecture + runId = $runId + generation = $generation + } + SelectionPath = $selectionPath + } +} + +function Resolve-LocalPackageRuntime { + param( + [string]$RuntimeDirectory, + [string]$Architecture, + [string]$NodeVersion, + [hashtable]$Operations + ) + + New-Item -Path $RuntimeDirectory -ItemType Directory -Force | Out-Null + $name = "node-v$NodeVersion-win-$Architecture.zip" + $path = Join-Path $RuntimeDirectory $name + if (Test-Path -LiteralPath $path -PathType Leaf) { + try { + & $Operations.TestArchive $path ([IO.Path]::GetFileNameWithoutExtension($name)) | Out-Null + Write-Host "Node.js runtime: cached ($name)" + return $path + } + catch { + Write-Host "Node.js runtime: replacing unusable cache ($($_.Exception.Message))" + Remove-Item -LiteralPath $path -Force + } + } + Write-Host "Node.js runtime: downloading $name" + & $Operations.DownloadRuntime $NodeVersion $name $path | Out-Null + & $Operations.TestArchive $path ([IO.Path]::GetFileNameWithoutExtension($name)) | Out-Null + return $path +} + +function New-LocalPackageLayout { + param( + [string]$LayoutDirectory, + [string]$RepositoryRoot, + [string]$HostExecutable, + [string]$PayloadDirectory, + [string]$RuntimeArchive, + [string]$Architecture, + [string]$Version + ) + + New-Item -Path $LayoutDirectory -ItemType Directory -Force | Out-Null + + $manifestPath = Join-Path $LayoutDirectory 'AppxManifest.xml' + [xml]$manifest = Get-Content -LiteralPath ( + Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\Package.appxmanifest' + ) -Raw + $identity = $manifest.SelectSingleNode("/*[local-name()='Package']/*[local-name()='Identity']") + $identity.SetAttribute('Version', $Version) + $identity.SetAttribute('ProcessorArchitecture', $Architecture) + $manifest.Save($manifestPath) + + Copy-Item -LiteralPath $HostExecutable -Destination (Join-Path $LayoutDirectory 'openclaw.exe') -Force + $images = Join-Path $LayoutDirectory 'Images' + if (Test-Path -LiteralPath $images) { Remove-Item -LiteralPath $images -Recurse -Force } + Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\Images') ` + -Destination $images -Recurse + + # A junction keeps the expanded application out of a second copy; the + # payload is hundreds of megabytes and never modified here. + $applicationLink = Join-Path $LayoutDirectory 'app' + $applicationTarget = Join-Path $PayloadDirectory 'app' + $existing = if (Test-Path -LiteralPath $applicationLink) { + Get-Item -LiteralPath $applicationLink -Force + } + else { $null } + if ($null -ne $existing -and + (($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or + $existing.Target -ne $applicationTarget)) { + Remove-Item -LiteralPath $applicationLink -Recurse -Force + $existing = $null + } + if ($null -eq $existing) { + New-Item -ItemType Junction -Path $applicationLink -Target $applicationTarget | Out-Null + } + + $runtime = Join-Path $LayoutDirectory 'runtime' + New-Item -Path $runtime -ItemType Directory -Force | Out-Null + foreach ($stale in Get-ChildItem -LiteralPath $runtime -File) { + if ($stale.Name -ne [IO.Path]::GetFileName($RuntimeArchive)) { + Remove-Item -LiteralPath $stale.FullName -Force + } + } + # Replace the layout copy every time. Testing only for a file of the right + # name would keep a truncated archive from an interrupted copy forever, + # including under -Force, and clawctl setup would then read the bad copy. + $runtimeTarget = Join-Path $runtime ([IO.Path]::GetFileName($RuntimeArchive)) + $runtimeStaging = "$runtimeTarget.$([guid]::NewGuid().ToString('N')).tmp" + try { + Copy-Item -LiteralPath $RuntimeArchive -Destination $runtimeStaging -Force + [IO.File]::Move($runtimeStaging, $runtimeTarget, $true) + } + finally { + if (Test-Path -LiteralPath $runtimeStaging) { + Remove-Item -LiteralPath $runtimeStaging -Force + } + } + return $manifestPath +} + +function Test-LocalPackageOwnership { + param($Installed, [string]$LayoutDirectory) + + # An identity can only have one registration, so "ours" means the installed + # location is this checkout's layout. Anything else belongs to another + # checkout or tool even though the name and publisher match. + if ($null -eq $Installed -or [string]::IsNullOrWhiteSpace($Installed.InstallLocation)) { + return $false + } + return [IO.Path]::GetFullPath($Installed.InstallLocation).TrimEnd('\') -ieq + [IO.Path]::GetFullPath($LayoutDirectory).TrimEnd('\') +} + +function Test-LocalPackageLayout { + param( + [string]$LayoutDirectory, + [string]$PayloadDirectory, + [string]$RuntimeArchiveName + ) + + # The registered package serves these files directly, so a short circuit + # must confirm the live layout, not just that a previous run wrote one. + foreach ($relative in @('AppxManifest.xml', 'openclaw.exe', "runtime\$RuntimeArchiveName")) { + if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory $relative) -PathType Leaf)) { + return $false + } + } + if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory 'Images') -PathType Container)) { + return $false + } + $link = Join-Path $LayoutDirectory 'app' + if (-not (Test-Path -LiteralPath $link -PathType Container)) { return $false } + $item = Get-Item -LiteralPath $link -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or + $item.Target -ne (Join-Path $PayloadDirectory 'app')) { + return $false + } + return (Test-Path -LiteralPath (Join-Path $link 'openclaw.mjs') -PathType Leaf) +} + +function Get-LocalPackageOperations { + return @{ + Now = { Get-Date } + Preflight = { + param($architecture) + if (-not $IsWindows) { throw 'Registering a local package requires Windows.' } + $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' + $unlock = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue + if ($null -eq $unlock -or + $null -eq $unlock.PSObject.Properties['AllowDevelopmentWithoutDevLicense'] -or + [int]$unlock.AllowDevelopmentWithoutDevLicense -ne 1) { + throw 'Developer Mode is required to register an unpackaged layout. Enable it in Settings > System > For developers.' + } + $osArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + if ($architecture -eq 'arm64' -and $osArchitecture -ne 'arm64') { + throw "An arm64 package cannot be registered on this $osArchitecture device." + } + if (-not (Get-Command dotnet -CommandType Application -ErrorAction SilentlyContinue)) { + throw 'The pinned .NET SDK is required. Install the SDK selected by global.json.' + } + $installer = Join-Path ( + [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + ) 'Microsoft Visual Studio\Installer' + if (-not (Get-Command vswhere.exe -CommandType Application -ErrorAction SilentlyContinue)) { + if (-not (Test-Path -LiteralPath (Join-Path $installer 'vswhere.exe') -PathType Leaf)) { + throw 'vswhere.exe was not found. Install Visual Studio Build Tools with the Desktop development with C++ workload.' + } + $env:Path = "$installer;$env:Path" + } + } + LatestRun = { + $gh = Get-LocalPackageGitHubCommand + $lines = @(& $gh.Source run list --repo openclaw/openclaw-windows-packaging ` + --workflow gateway-msix.yml --branch main --status success --limit 1 --json databaseId) + if ($LASTEXITCODE -ne 0) { + throw "Unable to query the payload workflow (exit $LASTEXITCODE). Check gh authentication, or pass -PayloadDirectory." + } + $runs = @([string]::Join("`n", [string[]]$lines) | ConvertFrom-Json) + if ($runs.Count -ne 1) { + throw 'No successful payload workflow run was found. Pass -PayloadRunId or -PayloadDirectory.' + } + return [long]$runs[0].databaseId + } + Download = { + param($runId, $architecture, $directory) + $gh = Get-LocalPackageGitHubCommand + & $gh.Source run download $runId --repo openclaw/openclaw-windows-packaging ` + --name "openclaw-gateway-payload-$architecture" --dir $directory | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "Payload download failed (exit $LASTEXITCODE). Check gh access and artifact retention, or pass -PayloadDirectory." + } + return $null + } + DownloadRuntime = { + param($nodeVersion, $name, $path) + Invoke-WebRequest -Uri "https://nodejs.org/dist/v$nodeVersion/$name" -OutFile $path + return $null + } + TestArchive = { + param($path, $expectedRoot) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($path) + try { + $entries = @($archive.Entries | Where-Object { $_.FullName -ieq "$expectedRoot/node.exe" }) + if ($entries.Count -ne 1) { + throw "The Node.js archive must contain exactly one '$expectedRoot/node.exe' entry." + } + } + finally { $archive.Dispose() } + return $null + } + Publish = { + param($project, $architecture, $output) + & dotnet publish $project --configuration Release --runtime "win-$architecture" ` + --self-contained "-p:Platform=$architecture" -p:PublishAot=true ` + --output $output --nologo | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "The NativeAOT publish failed (exit $LASTEXITCODE)." + } + return $null + } + GetPackage = { + param($name) + $package = Get-AppxPackage -Name $name -ErrorAction Stop | + Where-Object Name -eq $name | + Select-Object -First 1 + if ($null -eq $package) { return $null } + return [pscustomobject]@{ + Version = $package.Version.ToString() + PackageFullName = $package.PackageFullName + InstallLocation = $package.InstallLocation + IsDevelopmentMode = [bool]$package.IsDevelopmentMode + Status = $package.Status.ToString() + } + } + RegisterPackage = { + param($manifestPath) + Add-AppxPackage -Register $manifestPath -ErrorAction Stop + return $null + } + RemovePackage = { + param($packageFullName, $preserveData) + if ($preserveData) { + Remove-AppxPackage -Package $packageFullName -PreserveApplicationData -ErrorAction Stop + } + else { + Remove-AppxPackage -Package $packageFullName -ErrorAction Stop + } + return $null + } + TestPath = { param($path) Test-Path -LiteralPath $path } + RunSetup = { + # Control mode is selected by the alias name, so setup must go + # through clawctl.exe rather than the layout executable. + $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\clawctl.exe' + if (-not (Test-Path -LiteralPath $alias -PathType Leaf)) { + throw "The clawctl alias was not created at $alias." + } + & $alias setup | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "clawctl setup failed (exit $LASTEXITCODE)." + } + return $null + } + } +} + +function Invoke-LocalPackagePhase { + param([hashtable]$ProgressState, [string]$Label, [scriptblock]$Action) + + $ProgressState.Stage = $Label + Write-Host "`n[$Label]" + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $result = & $Action + $stopwatch.Stop() + Write-Host (' done in {0:0.00}s' -f $stopwatch.Elapsed.TotalSeconds) + return $result +} + +function Get-LocalPackageServices { + param([hashtable]$Overrides) + + $services = Get-LocalPackageOperations + foreach ($name in $Overrides.Keys) { + if (-not $services.ContainsKey($name) -or $Overrides[$name] -isnot [scriptblock]) { + throw "Unknown or invalid local package operation adapter: $name" + } + $services[$name] = $Overrides[$name] + } + return $services +} + +function Remove-LocalPackageRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [ValidateSet('x64', 'arm64')][string]$Architecture = 'x64', + [hashtable]$Operations = @{} + ) + + $services = Get-LocalPackageServices $Operations + $state = Join-Path ([IO.Path]::GetFullPath($RepositoryRoot)) "artifacts\local-package\$Architecture" + $layoutDirectory = Join-Path $state 'layout' + $installed = & $services.GetPackage $script:PackageName + if ($null -eq $installed) { + Write-Host "$script:PackageName is not registered for the current user." + } + elseif (-not $installed.IsDevelopmentMode) { + throw "$script:PackageName is installed from a package, not a local layout. Remove it deliberately with Remove-AppxPackage if that is what you want." + } + elseif (-not (Test-LocalPackageOwnership -Installed $installed -LayoutDirectory $layoutDirectory)) { + throw ( + "$script:PackageName is registered from another location: " + + "$($installed.InstallLocation). Run -Unregister from that checkout instead; " + + 'this one does not own that registration.' + ) + } + else { + # Development-mode packages allow preserving app data, so unregistering + # does not throw away the extracted Node.js runtime. + & $services.RemovePackage $installed.PackageFullName $true | Out-Null + Write-Host "Unregistered $($installed.PackageFullName); its app data was preserved." + } + $statePath = Join-Path $state 'state.json' + if (Test-Path -LiteralPath $statePath) { Remove-Item -LiteralPath $statePath -Force } + Write-Host "Cached payload and runtime are kept under $state." +} + +function Invoke-LocalPackageDeployment { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [ValidateSet('x64', 'arm64')][string]$Architecture = 'x64', + [string]$PayloadDirectory, + [long]$PayloadRunId, + [switch]$RefreshPayload, + [switch]$ReplaceExistingInstall, + [switch]$Force, + [switch]$SkipSetup, + [hashtable]$Operations = @{} + ) + + if ($PayloadRunId -lt 0) { throw '-PayloadRunId must be a positive workflow run ID.' } + if ($PayloadDirectory -and ($PayloadRunId -ne 0 -or $RefreshPayload)) { + throw '-PayloadDirectory cannot be combined with -PayloadRunId or -RefreshPayload.' + } + + $services = Get-LocalPackageServices $Operations + $root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path + $stateRoot = Join-Path $root "artifacts\local-package\$Architecture" + $layoutDirectory = Join-Path $stateRoot 'layout' + $statePath = Join-Path $stateRoot 'state.json' + $progress = @{ Stage = 'preparing' } + $total = [Diagnostics.Stopwatch]::StartNew() + + try { + & $services.Preflight $Architecture | Out-Null + New-Item -Path $stateRoot -ItemType Directory -Force | Out-Null + Write-Host "Registering a local development build of $script:PackageName ($Architecture)." + Write-Host "Layout: $layoutDirectory" + + $installed = Invoke-LocalPackagePhase $progress 'Check current registration' { + & $services.GetPackage $script:PackageName + } + if ($null -ne $installed -and -not $installed.IsDevelopmentMode) { + if (-not $ReplaceExistingInstall) { + throw ( + "$script:PackageName is already installed from a package (version $($installed.Version)). " + + 'Windows cannot replace a packaged install with a local layout. ' + + 'Re-run with -ReplaceExistingInstall to remove it first; its packaged app data ' + + 'cannot be preserved across that switch.' + ) + } + Write-Warning "Removing the packaged $script:PackageName $($installed.Version); its app data cannot be preserved." + & $services.RemovePackage $installed.PackageFullName $false | Out-Null + $installed = $null + } + elseif ($null -ne $installed -and + -not (Test-LocalPackageOwnership -Installed $installed -LayoutDirectory $layoutDirectory)) { + # A development registration from another checkout or tool. Only one + # registration of this identity can exist, but it is not ours to take. + if (-not $ReplaceExistingInstall) { + throw ( + "$script:PackageName is already registered from another location: " + + "$($installed.InstallLocation). Run -Unregister from that checkout, or " + + 're-run with -ReplaceExistingInstall to take over the identity here.' + ) + } + Write-Warning "Taking over the registration at $($installed.InstallLocation)." + } + + $payload = Invoke-LocalPackagePhase $progress 'Resolve payload' { + Resolve-LocalPackagePayload -CacheDirectory (Join-Path $stateRoot 'payloads') ` + -Architecture $Architecture -PayloadDirectory $PayloadDirectory ` + -PayloadRunId $PayloadRunId -RefreshPayload:$RefreshPayload -Operations $services + } + $runtimeArchive = Invoke-LocalPackagePhase $progress 'Resolve Node.js runtime' { + Resolve-LocalPackageRuntime -RuntimeDirectory (Join-Path $stateRoot 'runtime') ` + -Architecture $Architecture -NodeVersion $payload.NodeVersion -Operations $services + } + $hostDirectory = Join-Path $stateRoot 'host' + Invoke-LocalPackagePhase $progress 'Build launcher (NativeAOT)' { + & $services.Publish (Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj') ` + $Architecture $hostDirectory + } | Out-Null + $hostExecutable = Join-Path $hostDirectory 'openclaw.exe' + if (-not (& $services.TestPath $hostExecutable)) { + throw "The publish did not produce $hostExecutable." + } + + $hostInfo = Get-Item -LiteralPath $hostExecutable + $manifestSource = Join-Path $root 'src\OpenClaw.Launcher\Package.appxmanifest' + # Hash the launcher rather than trusting its timestamp: publish copies + # into the output directory and can refresh timestamps with no source + # change, which would defeat the up-to-date check on every run. + $hostHash = (Get-FileHash -LiteralPath $hostExecutable -Algorithm SHA256).Hash + $imageHashes = @( + Get-ChildItem -LiteralPath (Join-Path $root 'src\OpenClaw.Launcher\Images') -File -Recurse | + Sort-Object FullName | + ForEach-Object { "$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } + ) + $fingerprint = Get-LocalPackageFingerprint (@( + $Architecture + $payload.Directory + [IO.Path]::GetFileName($runtimeArchive) + $hostInfo.Length.ToString() + $hostHash + (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash + ) + $imageHashes) + $previous = Read-LocalPackageRecord $statePath + $setupSatisfied = $SkipSetup -or ($null -ne $previous -and $previous['setupComplete'] -eq $true) + if (-not $Force -and $null -ne $previous -and $null -ne $installed -and + $installed.IsDevelopmentMode -and + (Test-LocalPackageOwnership -Installed $installed -LayoutDirectory $layoutDirectory) -and + $previous['fingerprint'] -eq $fingerprint -and + $previous['version'] -eq $installed.Version -and + $installed.Status -ieq 'Ok' -and + $setupSatisfied -and + (Test-LocalPackageLayout -LayoutDirectory $layoutDirectory ` + -PayloadDirectory $payload.Directory ` + -RuntimeArchiveName ([IO.Path]::GetFileName($runtimeArchive)))) { + $total.Stop() + Write-Host "`nAlready up to date: $($installed.PackageFullName)" + Write-Host ('Total {0:0.00}s. Use -Force to re-register anyway.' -f $total.Elapsed.TotalSeconds) + return [pscustomobject]@{ + Version = $installed.Version + PackageFullName = $installed.PackageFullName + LayoutDirectory = $layoutDirectory + Changed = $false + } + } + + $version = Get-LocalPackageNextVersion ` + -InstalledVersion $(if ($null -ne $installed) { $installed.Version } else { '' }) ` + -PreviousVersion $(if ($null -ne $previous) { [string]$previous['version'] } else { '' }) ` + -Now (& $services.Now) + + $manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' { + New-LocalPackageLayout -LayoutDirectory $layoutDirectory -RepositoryRoot $root ` + -HostExecutable $hostExecutable -PayloadDirectory $payload.Directory ` + -RuntimeArchive $runtimeArchive -Architecture $Architecture -Version $version + } + Invoke-LocalPackagePhase $progress 'Register package' { + # Re-registering over an existing development registration does not + # reliably repair one whose layout was deleted or damaged: the + # package still reports Status Ok while its aliases fail with "The + # process has no package identity". Removing first, preserving app + # data, makes registration deterministic and self-healing. + if ($null -ne $installed -and $installed.IsDevelopmentMode) { + & $services.RemovePackage $installed.PackageFullName $true | Out-Null + } + & $services.RegisterPackage $manifestPath + } | Out-Null + + $registered = & $services.GetPackage $script:PackageName + if ($null -eq $registered -or $registered.Version -ne $version -or + -not $registered.IsDevelopmentMode -or $registered.Status -inotin @('Ok', 'Ready')) { + throw 'The package did not register as a healthy local development build.' + } + Write-LocalPackageRecord $statePath ([ordered]@{ + schemaVersion = $script:StateSchema + version = $version + fingerprint = $fingerprint + setupComplete = $false + packageFullName = $registered.PackageFullName + layoutDirectory = $layoutDirectory + payloadDirectory = $payload.Directory + }) + + if (-not $SkipSetup) { + Invoke-LocalPackagePhase $progress 'Prepare bundled Node.js runtime' { + & $services.RunSetup + } | Out-Null + } + + # Record completion only once the package is actually runnable, so a + # failed or skipped setup cannot be short-circuited as up to date. + Write-LocalPackageRecord $statePath ([ordered]@{ + schemaVersion = $script:StateSchema + version = $version + fingerprint = $fingerprint + setupComplete = (-not $SkipSetup) + packageFullName = $registered.PackageFullName + layoutDirectory = $layoutDirectory + payloadDirectory = $payload.Directory + }) + if ($null -ne $payload.PendingSelection) { + Write-LocalPackageRecord $payload.SelectionPath $payload.PendingSelection + } + if ($payload.Superseded) { + Remove-Item -LiteralPath $payload.Superseded -Recurse -Force -ErrorAction SilentlyContinue + } + + $total.Stop() + Write-Host "`nRegistered: $($registered.PackageFullName)" + Write-Host $(if ($SkipSetup) { + 'Run `clawctl setup` once, then `openclaw`.' + } + else { 'Ready to run: `openclaw`' }) + Write-Host ('Total {0:0.00}s.' -f $total.Elapsed.TotalSeconds) + return [pscustomobject]@{ + Version = $version + PackageFullName = $registered.PackageFullName + LayoutDirectory = $layoutDirectory + Changed = $true + } + } + catch { + $total.Stop() + Write-Host "`nFAILED during $($progress.Stage): $($_.Exception.Message)" + throw + } +} + +function Get-LocalPackageNextVersion { + param( + [string]$InstalledVersion, + [string]$PreviousVersion, + [datetime]$Now = (Get-Date) + ) + + $parse = { + param($value) + if (-not $value) { return [version]'0.0.0.0' } + $parsed = $null + if ($value -notmatch '^\d{1,5}\.\d{1,5}\.\d{1,5}\.\d{1,5}$' -or + -not [version]::TryParse($value, [ref]$parsed) -or + @($parsed.Major, $parsed.Minor, $parsed.Build, $parsed.Revision). + Where({ $_ -gt [uint16]::MaxValue }).Count -ne 0) { + throw "Invalid MSIX version '$value'. Use four numeric components from 0 through 65535." + } + return $parsed + } + $highest = & $parse $InstalledVersion + $previous = & $parse $PreviousVersion + if ($previous -gt $highest) { $highest = $previous } + + $days = [int]($Now.Date - [datetime]'2020-01-01').TotalDays + if ($days -lt 0 -or $days -gt [uint16]::MaxValue) { + throw 'The current date cannot be encoded as a local package version.' + } + $candidate = [version]"0.1.$days.$([int]$Now.TimeOfDay.TotalSeconds % 65536)" + if ($candidate -gt $highest) { return $candidate.ToString(4) } + + $parts = @($highest.Major, $highest.Minor, $highest.Build, $highest.Revision) + for ($index = 3; $index -ge 0; $index--) { + if ($parts[$index] -lt [uint16]::MaxValue) { + $parts[$index]++ + return $parts -join '.' + } + $parts[$index] = 0 + } + throw 'The local package version space is exhausted.' +} + +Export-ModuleMember -Function Invoke-LocalPackageDeployment, Remove-LocalPackageRegistration, + Get-LocalPackageNextVersion diff --git a/scripts/Test-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 new file mode 100644 index 00000000..59fab1cb --- /dev/null +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -0,0 +1,360 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LocalPackage.psm1') -Force + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-local-package-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $testRoot -ItemType Directory | Out-Null + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Assert-Fails { + param([scriptblock]$Action, [string]$Pattern) + try { & $Action | Out-Null } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "Expected '$Pattern'; received '$($_.Exception.Message)'." + } + return + } + throw "Expected failure matching '$Pattern'." +} + +function New-Fixture { + $root = Join-Path $testRoot "repo with spaces $([guid]::NewGuid().ToString('N'))" + $project = Join-Path $root 'src\OpenClaw.Launcher' + New-Item -Path (Join-Path $project 'Images') -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $project 'Images\StoreLogo.png'), 'fixture image') + [IO.File]::WriteAllText((Join-Path $project 'Package.appxmanifest'), @' + + + + +'@) + + $state = @{ + Root = $root + Installed = $null + Now = [datetime]'2026-09-14T12:00:00' + RunId = [long]500 + Offline = $false + PayloadText = 'first payload' + NodeVersion = '24.20.0' + DownloadFailure = $false + PublishFailure = $false + SkipHost = $false + HostVersion = 1 + RegisterFailure = $false + BadRegistration = $false + Queries = 0 + Downloads = 0 + RuntimeDownloads = 0 + Publishes = 0 + Registrations = 0 + Setups = 0 + SetupFailure = $false + Removals = @() + PreserveFlags = @() + } + $state.WritePayload = { + param($directory, $architecture, $text, $nodeVersion) + New-Item -Path (Join-Path $directory 'app') -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $directory 'app\openclaw.mjs'), $text) + [IO.File]::WriteAllText((Join-Path $directory 'payload-metadata.json'), (@{ + architecture = $architecture; layout = 'expanded-directory'; nodeVersion = $nodeVersion + } | ConvertTo-Json)) + } + $state.Operations = @{ + Now = { $state.Now }.GetNewClosure() + Preflight = { param($architecture) return $null } + LatestRun = { + if ($state.Offline) { throw 'GitHub is unavailable.' } + $state.Queries++ + return $state.RunId + }.GetNewClosure() + Download = { + param($runId, $architecture, $directory) + if ($state.Offline) { throw 'Downloads are unavailable.' } + $state.Downloads++ + & $state.WritePayload $directory $architecture $state.PayloadText $state.NodeVersion + if ($state.DownloadFailure) { throw 'Interrupted payload download.' } + return $null + }.GetNewClosure() + DownloadRuntime = { + param($nodeVersion, $name, $path) + if ($state.Offline) { throw 'nodejs.org is unavailable.' } + $state.RuntimeDownloads++ + [IO.File]::WriteAllText($path, "fixture archive $name") + return $null + }.GetNewClosure() + TestArchive = { param($path, $expectedRoot) return $null } + Publish = { + param($project, $architecture, $output) + $state.Publishes++ + if ($state.PublishFailure) { throw 'NativeAOT publish failed.' } + New-Item -Path $output -ItemType Directory -Force | Out-Null + if (-not $state.SkipHost) { + # Unchanged source must produce identical bytes, as a real + # incremental publish does; HostVersion models a source edit. + [IO.File]::WriteAllText((Join-Path $output 'openclaw.exe'), "host $($state.HostVersion)") + } + return $null + }.GetNewClosure() + GetPackage = { param($name) $state.Installed }.GetNewClosure() + RegisterPackage = { + param($manifestPath) + $state.Registrations++ + if ($state.RegisterFailure) { throw '0x80073CFB: registration blocked.' } + [xml]$m = Get-Content -LiteralPath $manifestPath -Raw + $state.Installed = [pscustomobject]@{ + Version = if ($state.BadRegistration) { '9.9.9.9' } else { $m.Package.Identity.Version } + PackageFullName = "OpenClaw.Gateway_$($m.Package.Identity.Version)_fixture" + InstallLocation = Split-Path $manifestPath -Parent + IsDevelopmentMode = $true + Status = 'Ok' + } + return $null + }.GetNewClosure() + RemovePackage = { + param($packageFullName, $preserveData) + $state.Removals += $packageFullName + $state.PreserveFlags += [bool]$preserveData + $state.Installed = $null + return $null + }.GetNewClosure() + TestPath = { param($path) Test-Path -LiteralPath $path } + RunSetup = { + $state.Setups++ + if ($state.SetupFailure) { throw 'clawctl setup failed (exit 1).' } + return $null + }.GetNewClosure() + } + return $state +} + +function Invoke-Fixture { + param([hashtable]$State, [hashtable]$Arguments = @{}) + Invoke-LocalPackageDeployment -RepositoryRoot $State.Root -Operations $State.Operations @Arguments +} + +try { + # Clean checkout to a registered package. + $f = New-Fixture + $first = Invoke-Fixture $f + Assert-True ($first.Changed -and $f.Downloads -eq 1 -and $f.RuntimeDownloads -eq 1 -and + $f.Publishes -eq 1 -and $f.Registrations -eq 1) 'First deployment did not acquire and register exactly once.' + Assert-True ($f.Setups -eq 1) 'Deployment did not leave the package runnable by preparing the runtime.' + Assert-True (@($first).Count -eq 1 -and $first.PackageFullName) 'Deployment did not return a single registration record.' + $layout = $first.LayoutDirectory + Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'first payload') 'Layout does not expose the payload application.' + Assert-True ((Get-Item (Join-Path $layout 'app')).Attributes -band [IO.FileAttributes]::ReparsePoint) 'The layout copied the application instead of linking it.' + Assert-True (Test-Path (Join-Path $layout 'openclaw.exe')) 'Layout is missing the launcher.' + Assert-True (Test-Path (Join-Path $layout 'Images\StoreLogo.png')) 'Layout is missing package images.' + Assert-True (@(Get-ChildItem (Join-Path $layout 'runtime') -File).Name -eq 'node-v24.20.0-win-x64.zip') 'Layout is missing the bundled Node.js runtime.' + [xml]$m = Get-Content (Join-Path $layout 'AppxManifest.xml') -Raw + Assert-True ($m.Package.Identity.Version -eq $first.Version -and + $m.Package.Identity.ProcessorArchitecture -eq 'x64') 'Manifest identity was not stamped.' + + # Idempotent: nothing changed, nothing done, and no network. + $f.Offline = $true + $second = Invoke-Fixture $f + Assert-True (-not $second.Changed) 'A no-change re-run reported work.' + Assert-True ($f.Registrations -eq 1 -and $f.Downloads -eq 1 -and $f.RuntimeDownloads -eq 1) 'A no-change re-run re-registered or re-downloaded.' + Assert-True ($second.Version -eq $first.Version) 'A no-change re-run altered the version.' + $forced = Invoke-Fixture $f @{ Force = $true } + Assert-True ($forced.Changed -and $f.Registrations -eq 2) '-Force did not re-register.' + Assert-True ([version]$forced.Version -gt [version]$first.Version) 'Re-registration did not advance the version.' + # Registering over a damaged development registration does not repair it, so + # the previous one must be removed first, preserving app data. + Assert-True (@($f.Removals).Count -eq 1 -and @($f.PreserveFlags)[0] -eq $true) 'Re-registration did not first remove the existing development registration.' + + # A source change must reach the registered package. + $f.Now = $f.Now.AddMinutes(5) + $f.HostVersion = 2 + $changed = Invoke-Fixture $f + Assert-True ($changed.Changed) 'A launcher change was not detected.' + Assert-True ((Get-Content (Join-Path $layout 'openclaw.exe') -Raw) -eq 'host 2') 'The layout kept a stale launcher.' + + # Payload refresh replaces content and retires the old generation only on success. + $f.Offline = $false + $f.PayloadText = 'refreshed payload' + $refreshed = Invoke-Fixture $f @{ RefreshPayload = $true } + Assert-True ($refreshed.Changed) 'Refresh reported no change.' + Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'refreshed payload') 'Refresh did not repoint the layout at new content.' + Assert-True ($f.Downloads -eq 2) 'Refresh did not download.' + $keptPayload = @(Get-ChildItem (Join-Path $f.Root 'artifacts\local-package\x64\payloads') -Directory).Count + Assert-True ($keptPayload -eq 1) 'A successful refresh did not retire the superseded payload.' + + # A refresh that fails to register must keep the previous payload selected. + $f.PayloadText = 'never registered' + $f.RegisterFailure = $true + $selectionPath = Join-Path $f.Root 'artifacts\local-package\x64\payloads\current.json' + $selectedBefore = (Get-Content $selectionPath -Raw | ConvertFrom-Json).generation + Assert-Fails { Invoke-Fixture $f @{ RefreshPayload = $true } } '0x80073CFB' + Assert-True (@(Get-ChildItem (Join-Path $f.Root 'artifacts\local-package\x64\payloads') -Directory).Count -eq 2) 'A failed registration discarded the previous payload.' + Assert-True ((Get-Content $selectionPath -Raw | ConvertFrom-Json).generation -eq $selectedBefore) 'A failed deployment left the unusable payload selected.' + $f.RegisterFailure = $false + + # Conflicting packaged install. + $g = New-Fixture + $g.Installed = [pscustomobject]@{ + Version = '1.2.3.4'; PackageFullName = 'OpenClaw.Gateway_1.2.3.4_x64__pkg' + InstallLocation = 'C:\Program Files\WindowsApps\fake'; IsDevelopmentMode = $false; Status = 'Ok' + } + Assert-Fails { Invoke-Fixture $g } 'already installed from a package' + Assert-True ($g.Registrations -eq 0 -and @($g.Removals).Count -eq 0) 'A conflicting packaged install was touched without consent.' + $replaced = Invoke-Fixture $g @{ ReplaceExistingInstall = $true } + Assert-True ($g.Removals -contains 'OpenClaw.Gateway_1.2.3.4_x64__pkg' -and $replaced.Changed) 'Explicit replacement did not remove the packaged install.' + # Removal happens first, so the dev build need not out-version the package it + # replaced; staying on 0.1.x keeps a later real release installable. + Assert-True ([version]$replaced.Version -lt [version]'1.0.0.0') 'A replacement build should not claim a release-range version.' + + # Failure paths stop before registering. + $h = New-Fixture + $h.PublishFailure = $true + Assert-Fails { Invoke-Fixture $h } 'publish failed' + Assert-True ($h.Registrations -eq 0) 'A failed publish still registered.' + $h.PublishFailure = $false + $h.SkipHost = $true + Assert-Fails { Invoke-Fixture $h } 'did not produce' + Assert-True ($h.Registrations -eq 0) 'A missing launcher still registered.' + $h.SkipHost = $false + $h.BadRegistration = $true + Assert-Fails { Invoke-Fixture $h } 'healthy local development build' + $h.BadRegistration = $false + $h.SetupFailure = $true + Assert-Fails { Invoke-Fixture $h } 'clawctl setup failed' + $h.SetupFailure = $false + $h.HostVersion = 99 + $setupsBefore = $h.Setups + $skipped = Invoke-Fixture $h @{ SkipSetup = $true } + Assert-True ($skipped.Changed) '-SkipSetup did not deploy.' + Assert-True ($h.Setups -eq $setupsBefore) '-SkipSetup still prepared the runtime.' + + $i = New-Fixture + $i.DownloadFailure = $true + Assert-Fails { Invoke-Fixture $i } 'Interrupted payload download' + Assert-True ($i.Registrations -eq 0) 'A failed download still registered.' + $i.Offline = $true + Assert-Fails { Invoke-Fixture $i } 'unavailable|cache' + + # Argument guards. + $j = New-Fixture + $external = Join-Path $testRoot 'supplied payload with spaces' + & $j.WritePayload $external 'x64' 'supplied' '24.20.0' + $supplied = Invoke-Fixture $j @{ PayloadDirectory = $external } + Assert-True ($j.Downloads -eq 0 -and $j.Queries -eq 0) 'A supplied payload still contacted GitHub.' + Assert-True ((Get-Content (Join-Path $supplied.LayoutDirectory 'app\openclaw.mjs') -Raw) -eq 'supplied') 'A supplied payload was not used.' + Assert-Fails { Invoke-Fixture $j @{ PayloadDirectory = $external; RefreshPayload = $true } } 'cannot be combined' + Assert-Fails { Invoke-Fixture $j @{ PayloadDirectory = $external; PayloadRunId = [long]7 } } 'cannot be combined' + Assert-Fails { Invoke-Fixture $j @{ PayloadRunId = [long]-1 } } 'positive workflow run' + Assert-Fails { + Invoke-LocalPackageDeployment -RepositoryRoot $j.Root -Operations @{ NotAnOperation = { } } + } 'Unknown or invalid local package operation adapter' + Assert-Fails { + Invoke-LocalPackageDeployment -RepositoryRoot $j.Root -Operations @{ Preflight = 'not a scriptblock' } + } 'Unknown or invalid local package operation adapter' + + # Unregister. + $k = New-Fixture + $deployed = Invoke-Fixture $k + Remove-LocalPackageRegistration -RepositoryRoot $k.Root -Operations $k.Operations + Assert-True ($k.Removals -contains $deployed.PackageFullName) 'Unregister did not remove the local registration.' + Assert-True ($k.PreserveFlags -contains $true) 'Unregister discarded the package app data it could have preserved.' + Assert-True (-not (Test-Path (Join-Path $k.Root 'artifacts\local-package\x64\state.json'))) 'Unregister left deployment state behind.' + Assert-True (Test-Path (Join-Path $k.Root 'artifacts\local-package\x64\payloads')) 'Unregister discarded the payload cache.' + $k.Installed = [pscustomobject]@{ + Version = '1.0.0.0'; PackageFullName = 'pkg'; InstallLocation = 'x' + IsDevelopmentMode = $false; Status = 'Ok' + } + Assert-Fails { Remove-LocalPackageRegistration -RepositoryRoot $k.Root -Operations $k.Operations } 'not a local layout' + + # Version selection. + Assert-True ((Get-LocalPackageNextVersion -InstalledVersion '2026.1.0.65535' -Now ([datetime]'2026-09-14')) -eq '2026.1.1.0') 'Revision rollover failed.' + Assert-Fails { Get-LocalPackageNextVersion -InstalledVersion '65535.65535.65535.65535' } 'version space is exhausted' + Assert-Fails { Get-LocalPackageNextVersion -InstalledVersion '1.2.3' } 'Invalid MSIX version' + # A failed or skipped setup must not be recorded as a complete deployment. + $s = New-Fixture + $s.SetupFailure = $true + Assert-Fails { Invoke-Fixture $s } 'clawctl setup failed' + $s.SetupFailure = $false + $recovered = Invoke-Fixture $s + Assert-True ($recovered.Changed -and $s.Setups -ge 2) 'A run after a failed setup was short-circuited as up to date.' + $afterRecovery = Invoke-Fixture $s + Assert-True (-not $afterRecovery.Changed) 'A completed deployment did not settle.' + + $sk = New-Fixture + $skipDeploy = Invoke-Fixture $sk @{ SkipSetup = $true } + Assert-True ($sk.Setups -eq 0 -and $skipDeploy.Changed) '-SkipSetup prepared the runtime.' + $afterSkip = Invoke-Fixture $sk + Assert-True ($afterSkip.Changed -and $sk.Setups -eq 1) 'A run after -SkipSetup did not complete the setup it skipped.' + + # A damaged live layout must not be reported as up to date. + foreach ($break in @('openclaw.exe', 'Images', 'app', 'runtime')) { + $d = New-Fixture + $deployed = Invoke-Fixture $d + $target = Join-Path $deployed.LayoutDirectory $break + Remove-Item -LiteralPath $target -Recurse -Force + $repaired = Invoke-Fixture $d + Assert-True ($repaired.Changed) "A layout missing '$break' was reported as up to date." + Assert-True (Test-Path -LiteralPath $target) "A layout missing '$break' was not repaired." + } + + # The layout runtime copy is replaced, not trusted by name. + $c = New-Fixture + $cDeployed = Invoke-Fixture $c + $archive = Get-ChildItem (Join-Path $cDeployed.LayoutDirectory 'runtime') -File | Select-Object -First 1 + [IO.File]::WriteAllText($archive.FullName, 'truncated') + $c.HostVersion = 2 + Invoke-Fixture $c | Out-Null + Assert-True ((Get-Content $archive.FullName -Raw) -ne 'truncated') 'A corrupt layout runtime archive survived redeployment.' + + # A development registration owned by another location is not taken over. + $o = New-Fixture + $o.Installed = [pscustomobject]@{ + Version = '0.1.0.0'; PackageFullName = 'OpenClaw.Gateway_0.1.0.0_x64__other' + InstallLocation = (Join-Path $testRoot 'someone elses layout') + IsDevelopmentMode = $true; Status = 'Ok' + } + Assert-Fails { Invoke-Fixture $o } 'registered from another location' + Assert-True ($o.Registrations -eq 0 -and @($o.Removals).Count -eq 0) 'A foreign registration was touched without consent.' + Assert-Fails { + Remove-LocalPackageRegistration -RepositoryRoot $o.Root -Operations $o.Operations + } 'does not own that registration' + $takenOver = Invoke-Fixture $o @{ ReplaceExistingInstall = $true } + Assert-True ($takenOver.Changed) 'Explicit take-over did not register.' + + # A foreign registration whose version matches this checkout's retained + # state satisfies every other up-to-date condition, so only an ownership + # check stops the short circuit from silently skipping the takeover. + $fo = New-Fixture + $mine = Invoke-Fixture $fo + $foreignLayout = Join-Path $testRoot 'other checkout layout' + New-Item -Path $foreignLayout -ItemType Directory -Force | Out-Null + $fo.Installed = [pscustomobject]@{ + Version = $mine.Version + PackageFullName = "OpenClaw.Gateway_$($mine.Version)_x64__other" + InstallLocation = $foreignLayout + IsDevelopmentMode = $true + Status = 'Ok' + } + Assert-Fails { Invoke-Fixture $fo } 'registered from another location' + $registrationsBefore = $fo.Registrations + $reclaimed = Invoke-Fixture $fo @{ ReplaceExistingInstall = $true } + Assert-True ($reclaimed.Changed) 'A same-version foreign registration was reported as up to date.' + Assert-True ($fo.Registrations -eq $registrationsBefore + 1) 'Take-over did not actually register.' + Assert-True ( + [IO.Path]::GetFullPath($fo.Installed.InstallLocation).TrimEnd('\') -ieq + [IO.Path]::GetFullPath($mine.LayoutDirectory).TrimEnd('\') + ) 'The aliases still resolve to the other checkout after take-over.' + + Write-Host 'Local package deployment scenarios passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +}