diff --git a/README.md b/README.md index 4e6597d1..64d65790 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,43 @@ the `NO_COLOR` environment variable, redirected output, and CI disable color; explicitly disabled. JSON output never contains terminal escape sequences. Help and version requests take precedence over the rest of the command line. -`clawctl --version bogus` prints the launcher version and exits `0` rather than +`clawctl --version bogus` reports the build identity and exits `0` rather than reporting `bogus`, because the version request is satisfied before the -remaining arguments are validated. The version printed is always the packaged -launcher's assembly version, including when the launcher is hosted by another -process. +remaining arguments are validated. + +`clawctl --version` reports the package version and packaging-repository +commit alongside the bundled OpenClaw payload version and its commit: + +```text +clawctl 0.0.0.1 + + Package: 0.0.0.1 (bfcb5ba73e7ea3e88ceed8c326e58e5baadc3191) + Payload: 2026.8.2 (0965053fe6b9341776df147a6934b7485c60b5ca) +``` + +Each commit is the one that produced the version it follows, and is muted so +the version stays the value a reader compares. + +`clawctl --version --json` reports the same identity as a versioned document, +so a support or deployment script can collect it without parsing prose: + +```json +{ + "ok": true, + "schemaVersion": 1, + "command": "version", + "package": { "version": "0.0.0.1", "commit": "bfcb5ba…" }, + "payload": { "version": "2026.8.2", "commit": "0965053…" } +} +``` + +Those four values are compiled into the binary as constants by the build that +produces the package, so the report cannot drift from the payload it shipped +with and costs no file or process access at startup. `Build-MSIX.ps1` supplies +the versions and commits it also records in `msix-metadata.json`; an ordinary +build falls back to the pin recorded in `release-policy.json`, and reports +`unknown` for a value no build supplied. The report never comes from the entry +assembly, so it stays correct when the launcher is hosted by another process. Response-file expansion is disabled. A leading `@` has no meaning to `clawctl` and is reported as an unrecognized argument rather than read from disk. diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 82c823a7..2b93289e 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -428,6 +428,10 @@ try { "-p:AssemblyVersion=$PackageVersion" ` "-p:FileVersion=$PackageVersion" ` "-p:PackageIdentityVersion=$PackageVersion" ` + "-p:ClawCtlPackageVersion=$PackageVersion" ` + "-p:ClawCtlPackageCommit=$($SourceCommit.ToLowerInvariant())" ` + "-p:ClawCtlPayloadVersion=$([string]$payloadInfo.packageVersion)" ` + "-p:ClawCtlPayloadCommit=$($payloadInfo.resolvedCommit.ToLowerInvariant())" ` "-p:AppxPackageDir=$appxOutput" ` -p:AppxBundle=Never ` -p:AppxPackageSigningEnabled=false ` diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 index b2eb4c47..625c9d9d 100644 --- a/scripts/LocalPackage.psm1 +++ b/scripts/LocalPackage.psm1 @@ -193,6 +193,21 @@ function Assert-LocalPackagePayload { return ([string]$metadata['nodeVersion']).TrimStart('v') } +function Get-LocalPackageCheckoutCommit { + param( + [scriptblock]$FindGit = { + @(Get-Command git -CommandType Application -ErrorAction SilentlyContinue) | + Select-Object -First 1 + } + ) + + $git = & $FindGit + if ($null -eq $git) { return '' } + $commit = (& $git.Source -C $PSScriptRoot rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $commit) { return '' } + return $commit.Trim().ToLowerInvariant() +} + function Assert-LocalPackagePayloadIsSafe { param([string]$Directory) @@ -575,9 +590,23 @@ function Get-LocalPackageOperations { return $null } Publish = { - param($project, $architecture, $output) + param($project, $architecture, $output, $metadata) + # Bake the checkout's commit into the local build so `clawctl + # --version` identifies what was deployed. A detached or missing + # git falls back to the project's own default. + $metadataArgs = @() + $commit = Get-LocalPackageCheckoutCommit + if ($commit) { + $metadataArgs += "-p:ClawCtlPackageCommit=$commit" + } + if ($null -ne $metadata) { + $metadataArgs += "-p:ClawCtlPackageVersion=$($metadata.PackageVersion)" + $metadataArgs += "-p:ClawCtlPayloadVersion=$($metadata.PayloadVersion)" + $metadataArgs += "-p:ClawCtlPayloadCommit=$($metadata.PayloadCommit)" + } & dotnet publish $project --configuration Release --runtime "win-$architecture" ` --self-contained "-p:Platform=$architecture" -p:PublishAot=true ` + @metadataArgs ` --output $output --nologo | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -ne 0) { @@ -753,6 +782,40 @@ function Invoke-LocalPackageDeployment { -Architecture $Architecture -PayloadDirectory $PayloadDirectory ` -PayloadRunId $PayloadRunId -RefreshPayload:$RefreshPayload -Operations $services } + $payloadMetadata = Read-LocalPackageRecord ( + Join-Path $payload.Directory 'payload-metadata.json' + ) + $previous = Read-LocalPackageRecord $statePath + $now = & $services.Now + $publishVersion = if ($null -ne $installed -and + $installed.IsDevelopmentMode -and + $null -ne $previous -and + $previous['version'] -eq $installed.Version) { + $installed.Version + } + else { + Get-LocalPackageNextVersion ` + -InstalledVersion $(if ($null -ne $installed) { $installed.Version } else { '' }) ` + -PreviousVersion $(if ($null -ne $previous) { [string]$previous['version'] } else { '' }) ` + -Now $now + } + $publishMetadata = @{ + PackageVersion = $publishVersion + PayloadVersion = if ([string]::IsNullOrWhiteSpace( + [string]$payloadMetadata['packageVersion'])) { + 'unknown' + } + else { + [string]$payloadMetadata['packageVersion'] + } + PayloadCommit = if ( + [string]$payloadMetadata['resolvedCommit'] -match '^[0-9a-fA-F]{40}$') { + ([string]$payloadMetadata['resolvedCommit']).ToLowerInvariant() + } + else { + 'unknown' + } + } $runtimeArchive = Invoke-LocalPackagePhase $progress 'Resolve Node.js runtime' { Resolve-LocalPackageRuntime -RuntimeDirectory (Join-Path $stateRoot 'runtime') ` -Architecture $Architecture -NodeVersion $payload.NodeVersion -Operations $services @@ -764,12 +827,12 @@ function Invoke-LocalPackageDeployment { $hostDirectory = Join-Path $stateRoot 'host' Invoke-LocalPackagePhase $progress 'Build launcher (NativeAOT)' { & $services.Publish (Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj') ` - $Architecture $hostDirectory + $Architecture $hostDirectory $publishMetadata } | Out-Null $sessionHostDirectory = Join-Path $stateRoot 'session-host' Invoke-LocalPackagePhase $progress 'Build session host (NativeAOT)' { & $services.Publish (Join-Path $root 'src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj') ` - $Architecture $sessionHostDirectory + $Architecture $sessionHostDirectory $null } | Out-Null $hostExecutable = Join-Path $hostDirectory 'openclaw.exe' if (-not (& $services.TestPath $hostExecutable)) { @@ -812,7 +875,6 @@ function Invoke-LocalPackageDeployment { $sessionHostHash (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash ) + $imageHashes + $mxcHashes) - $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 @@ -839,7 +901,27 @@ function Invoke-LocalPackageDeployment { $version = Get-LocalPackageNextVersion ` -InstalledVersion $(if ($null -ne $installed) { $installed.Version } else { '' }) ` -PreviousVersion $(if ($null -ne $previous) { [string]$previous['version'] } else { '' }) ` - -Now (& $services.Now) + -Now $now + if ($version -ne $publishVersion) { + $publishMetadata.PackageVersion = $version + Invoke-LocalPackagePhase $progress 'Rebuild launcher with package identity' { + & $services.Publish ( + Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj' + ) $Architecture $hostDirectory $publishMetadata + } | Out-Null + $hostInfo = Get-Item -LiteralPath $hostExecutable + $hostHash = (Get-FileHash -LiteralPath $hostExecutable -Algorithm SHA256).Hash + $fingerprint = Get-LocalPackageFingerprint (@( + $Architecture + $payload.Directory + [IO.Path]::GetFileName($runtimeArchive) + $hostInfo.Length.ToString() + $hostHash + $sessionHostInfo.Length.ToString() + $sessionHostHash + (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash + ) + $imageHashes + $mxcHashes) + } $manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' { New-LocalPackageLayout -LayoutDirectory $layoutDirectory -RepositoryRoot $root ` diff --git a/scripts/Test-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 index fd99515c..b59352a6 100644 --- a/scripts/Test-Deploy-LocalPackage.Tests.ps1 +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -104,13 +104,18 @@ function New-Fixture { SetupFailure = $false Removals = @() PreserveFlags = @() + PublishMetadata = $null } $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 + architecture = $architecture + layout = 'expanded-directory' + nodeVersion = $nodeVersion + packageVersion = '2026.9.4' + resolvedCommit = '0965053fe6b9341776df147a6934b7485c60b5ca' } | ConvertTo-Json)) } $state.Operations = @{ @@ -147,8 +152,11 @@ function New-Fixture { return $null }.GetNewClosure() Publish = { - param($project, $architecture, $output) + param($project, $architecture, $output, $metadata) $state.Publishes++ + if ($project -like '*OpenClaw.Launcher.csproj') { + $state.PublishMetadata = $metadata + } if ($state.PublishFailure) { throw 'NativeAOT publish failed.' } New-Item -Path $output -ItemType Directory -Force | Out-Null $isSessionHost = $project -like '*OpenClaw.SessionHost*' @@ -159,8 +167,17 @@ function New-Fixture { } elseif (-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)") + # incremental publish does; HostVersion models a source edit + # and metadata models generated compile-time constants. + $identity = if ($null -eq $metadata) { + '' + } + else { + $metadata | ConvertTo-Json -Compress + } + [IO.File]::WriteAllText( + (Join-Path $output 'openclaw.exe'), + "host $($state.HostVersion) $identity") } return $null }.GetNewClosure() @@ -212,6 +229,17 @@ try { $f.MxcStages -eq 1 -and $f.Publishes -eq 2 -and $f.Registrations -eq 1) 'First deployment did not acquire, build, and register exactly once.' Assert-True ($f.Setups -eq 1) 'Deployment did not leave the package runnable by preparing the runtime.' + Assert-True ( + $f.PublishMetadata.PackageVersion -eq $first.Version -and + $f.PublishMetadata.PayloadVersion -eq '2026.9.4' -and + $f.PublishMetadata.PayloadCommit -eq '0965053fe6b9341776df147a6934b7485c60b5ca' + ) 'Launcher publish did not receive the selected local package and payload identity.' + $localPackageModule = Get-Module LocalPackage + Assert-True ( + (& $localPackageModule { + Get-LocalPackageCheckoutCommit -FindGit { $null } + }) -eq '' + ) 'Missing Git should leave optional checkout metadata empty.' Assert-True (@($f.SetupPackageFamilyNames)[0] -eq 'OpenClaw.Gateway_fixture') 'Setup did not target the owning package family.' Assert-True (@($first).Count -eq 1 -and $first.PackageFullName) 'Deployment did not return a single registration record.' $layout = $first.LayoutDirectory @@ -254,7 +282,13 @@ try { $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.' + $changedLauncher = Get-Content (Join-Path $layout 'openclaw.exe') -Raw + Assert-True ( + $changedLauncher.StartsWith('host 2 ', [StringComparison]::Ordinal) -and + $changedLauncher.Contains( + "`"PackageVersion`":`"$($changed.Version)`"", + [StringComparison]::Ordinal) + ) 'The layout kept a stale launcher or package identity.' # Payload refresh replaces content and retires the old generation only on success. $f.Offline = $false @@ -327,6 +361,21 @@ try { $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.' + $legacy = New-Fixture + $legacyPayload = Join-Path $testRoot 'legacy supplied payload' + & $legacy.WritePayload $legacyPayload 'x64' 'legacy' '24.20.0' + $legacyMetadataPath = Join-Path $legacyPayload 'payload-metadata.json' + $legacyMetadata = Get-Content -LiteralPath $legacyMetadataPath -Raw | ConvertFrom-Json + $legacyMetadata.PSObject.Properties.Remove('packageVersion') + $legacyMetadata.PSObject.Properties.Remove('resolvedCommit') + [IO.File]::WriteAllText( + $legacyMetadataPath, + ($legacyMetadata | ConvertTo-Json)) + Invoke-Fixture $legacy @{ PayloadDirectory = $legacyPayload } | Out-Null + Assert-True ( + $legacy.PublishMetadata.PayloadVersion -eq 'unknown' -and + $legacy.PublishMetadata.PayloadCommit -eq 'unknown' + ) 'A legacy supplied payload did not use safe unknown identity fallbacks.' 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' diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 073f65c4..8554a83a 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -203,7 +203,7 @@ public static RootCommand Create( // prints help and succeeds instead of reporting a missing command. var helpAction = new ClawCtlHelpAction(noColor); root.SetAction((parseResult, _) => Task.FromResult(helpAction.Invoke(parseResult))); - UseLauncherVersion(root); + UseLauncherVersion(root, json, noColor); UseClawCtlHelp(root, helpAction); return root; @@ -225,31 +225,71 @@ private static void UseClawCtlHelp(RootCommand root, ClawCtlHelpAction helpActio } // The built-in version action reports the entry assembly, which is the test - // or scenario host rather than the launcher. Report the launcher assembly so - // the value identifies the shipped package binary in every host. - private static void UseLauncherVersion(RootCommand root) + // or scenario host rather than the launcher. Report the build identity that + // was compiled into this binary so the value identifies the shipped package + // in every host. + private static void UseLauncherVersion( + RootCommand root, + Option json, + Option noColor) { foreach (Option option in root.Options) { if (option is VersionOption versionOption) { - versionOption.Action = new LauncherVersionAction(); + versionOption.Action = new LauncherVersionAction(json, noColor); } } } - private sealed class LauncherVersionAction : SynchronousCommandLineAction + private sealed class LauncherVersionAction(Option json, Option noColor) + : SynchronousCommandLineAction { public override bool ClearsParseErrors => true; public override int Invoke(ParseResult parseResult) { - string version = typeof(LauncherVersionAction).Assembly - .GetName() - .Version? - .ToString() ?? "unknown"; - parseResult.InvocationConfiguration.Output.WriteLine(version); + ArgumentNullException.ThrowIfNull(parseResult); + + TextWriter output = parseResult.InvocationConfiguration.Output; + bool jsonValue = GetBooleanValue(parseResult, json, defaultValue: false); + if (jsonValue) + { + ClawCtlJson.WriteVersion(output); + return 0; + } + + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareOutput( + GetBooleanValue(parseResult, noColor, defaultValue: true), + json: false, + ReferenceEquals(output, Console.Out), + WindowsHostConsole.Instance.IsInteractive, + Environment.GetEnvironmentVariable, + () => WindowsHostConsole.Instance + .TryEnableVirtualTerminalProcessing(output, _ => { }, out restore)); + + using (restore) + { + ClawCtlConsole.WriteVersion(output, useColor); + } + return 0; } + + private static bool GetBooleanValue( + ParseResult parseResult, + Option option, + bool defaultValue) + { + try + { + return parseResult.GetValue(option); + } + catch (InvalidOperationException) + { + return defaultValue; + } + } } } diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 77aa19d5..def377e6 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -77,6 +77,38 @@ internal static void WriteResult( } } + internal static void WriteVersion(TextWriter output, bool useColor = false) + { + ArgumentNullException.ThrowIfNull(output); + + var view = new ResultView( + SupportsUnicode(output), + ClawCtlBuildMetadata.PackageVersion, + ResolveWidth(output)); + view.Row( + "Package", + VersionValue( + ClawCtlBuildMetadata.PackageVersion, + ClawCtlBuildMetadata.PackageCommit)); + view.Row( + "Payload", + VersionValue( + ClawCtlBuildMetadata.PayloadVersion, + ClawCtlBuildMetadata.PayloadCommit)); + Render(output, view.Build(), useColor, view.Unicode); + } + + // The version is what a user compares; the commit is what support pastes + // into a bug. Keeping the commit on the same row as a muted parenthetical + // says that without spending a second label on it. + private static Paragraph VersionValue(string version, string commit) + { + var paragraph = new Paragraph(); + paragraph.Append(version); + paragraph.Append($" ({commit})", MutedStyle); + return paragraph; + } + internal static void WriteHelp( TextWriter output, ClawCtlHelpModel model, diff --git a/src/OpenClaw.Launcher/ClawCtlJson.cs b/src/OpenClaw.Launcher/ClawCtlJson.cs index 13391af1..7b18dacf 100644 --- a/src/OpenClaw.Launcher/ClawCtlJson.cs +++ b/src/OpenClaw.Launcher/ClawCtlJson.cs @@ -9,6 +9,8 @@ internal sealed record ClawCtlJsonDocument( bool Ok, int SchemaVersion, string Command, + ClawCtlJsonBuild? Package = null, + ClawCtlJsonBuild? Payload = null, ClawCtlJsonSession? Session = null, ClawCtlJsonRuntime? Runtime = null, ClawCtlJsonGateway? Gateway = null, @@ -17,6 +19,10 @@ internal sealed record ClawCtlJsonDocument( ClawCtlJsonWarning? Warning = null, ClawCtlJsonError? Error = null); +// A version and the commit that produced it. Reported for the package and for +// the OpenClaw payload it carries. +internal sealed record ClawCtlJsonBuild(string Version, string Commit); + internal sealed record ClawCtlJsonSession( string State, string? SandboxId = null, @@ -71,6 +77,26 @@ internal static void WriteResult(TextWriter output, IClawCtlResult result) ClawCtlJsonContext.Default.ClawCtlJsonDocument)); } + // The build identity is known at compile time, so the version document is + // produced directly rather than from a command result. --version is handled + // by the version option before command dispatch and has no result to + // project. + internal static void WriteVersion(TextWriter output) + { + ArgumentNullException.ThrowIfNull(output); + + Write(output, new ClawCtlJsonDocument( + true, + SchemaVersion, + "version", + Package: new ClawCtlJsonBuild( + ClawCtlBuildMetadata.PackageVersion, + ClawCtlBuildMetadata.PackageCommit), + Payload: new ClawCtlJsonBuild( + ClawCtlBuildMetadata.PayloadVersion, + ClawCtlBuildMetadata.PayloadCommit))); + } + internal static void WriteFailure(TextWriter output, string command, string message) { ArgumentNullException.ThrowIfNull(output); diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index 5073c2d1..e2de03f2 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -64,6 +64,73 @@ + + + <_ReleasePolicyPath>$(MSBuildThisFileDirectory)..\..\release-policy.json + <_ReleasePolicyText Condition="Exists('$(_ReleasePolicyPath)')">$([System.IO.File]::ReadAllText('$(_ReleasePolicyPath)')) + + $(PackageIdentityVersion) + <_GatewayTag Condition="'$(ClawCtlPackageVersion)' == '' and '$(_ReleasePolicyText)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"gatewayTag"\s*:\s*"([^"]*)".*$', '$1')) + <_MSIXRevision Condition="'$(ClawCtlPackageVersion)' == '' and '$(_ReleasePolicyText)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"msixRevision"\s*:\s*(\d+).*$','$1')) + <_GatewayYear Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$', '$1')) + <_GatewayMonth Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$', '$2')) + <_GatewayPatch Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$', '$3')) + <_GatewayReleaseSequence Condition="'$(_GatewayTag)' != ''">$([System.Text.RegularExpressions.Regex]::Replace($(_GatewayTag), '^v(\d+)\.(\d+)\.(\d+)-(\d+)$', '$4')) + <_GatewayReleaseSequence Condition="'$(_GatewayReleaseSequence)' == '$(_GatewayTag)'">1 + <_PackageRevisionBase Condition="'$(_GatewayReleaseSequence)' != ''">$([MSBuild]::Multiply($(_GatewayReleaseSequence), 1000)) + <_PackageRevision Condition="'$(_PackageRevisionBase)' != '' and '$(_MSIXRevision)' != ''">$([MSBuild]::Add($(_PackageRevisionBase), $(_MSIXRevision))) + $(_GatewayYear).$(_GatewayMonth).$(_GatewayPatch).$(_PackageRevision) + $([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"payloadPackageVersion"\s*:\s*"([^"]*)".*$', '$1')) + $([System.Text.RegularExpressions.Regex]::Replace($(_ReleasePolicyText), '(?s)^.*"approvedCommit"\s*:\s*"([^"]*)".*$', '$1')) + $(SourceRevisionId) + + unknown + unknown + unknown + unknown + + + + + <_BuildMetadataFile>$(IntermediateOutputPath)ClawCtlBuildMetadata.g.cs + + + <_BuildMetadataLine Include="// <auto-generated />" /> + <_BuildMetadataLine Include="namespace OpenClaw.Launcher%3B" /> + <_BuildMetadataLine Include="internal static class ClawCtlBuildMetadata" /> + <_BuildMetadataLine Include="{" /> + <_BuildMetadataLine Include=" public const string PackageVersion = "$(ClawCtlPackageVersion)"%3B" /> + <_BuildMetadataLine Include=" public const string PackageCommit = "$(ClawCtlPackageCommit)"%3B" /> + <_BuildMetadataLine Include=" public const string PayloadVersion = "$(ClawCtlPayloadVersion)"%3B" /> + <_BuildMetadataLine Include=" public const string PayloadCommit = "$(ClawCtlPayloadCommit)"%3B" /> + <_BuildMetadataLine Include="}" /> + + + + + + + + + + diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 23035d9f..bbc69104 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -45,6 +45,7 @@ private static async Task Main() ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), ("JSON failures survive NativeAOT", JsonFailureIsStructuredAsync), + ("version JSON survives NativeAOT", VersionJsonIsStructuredAsync), ("Spectre renders clawctl output under NativeAOT", SpectreOutputRenders), ("missing application reports diagnostics", MissingApplicationReportsAsync), ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync) @@ -137,25 +138,60 @@ private static async Task SetupHelpPrintsCommandHelpAsync() // This driver's assembly version is 9.9.9.9. The library's built-in action // reports the entry assembly, so if the custom action were ever dropped - // this scenario would print 9.9.9.9 instead of the launcher's version. + // this scenario would print 9.9.9.9 instead of the baked build identity. private static async Task VersionReportsLauncherAssemblyAsync() { using Fixture fixture = Fixture.CreateWithoutApplication(); - string launcherVersion = LauncherVersion(); string driverVersion = DriverVersion(); int exitCode = await fixture.RunAsync(["--version"]).ConfigureAwait(false); AssertExitCode(0, exitCode, fixture); - string reported = fixture.Output.ToString().Trim(); - Assert( - string.Equals(reported, launcherVersion, StringComparison.Ordinal), - $"Expected the launcher version '{launcherVersion}' but got '{reported}'."); + string reported = fixture.Output.ToString(); + foreach (string expected in new[] + { + ClawCtlBuildMetadata.PackageVersion, + ClawCtlBuildMetadata.PackageCommit, + ClawCtlBuildMetadata.PayloadVersion, + ClawCtlBuildMetadata.PayloadCommit + }) + { + Assert( + reported.Contains(expected, StringComparison.Ordinal), + $"Expected the version report to contain '{expected}'."); + } + Assert( - !string.Equals(reported, driverVersion, StringComparison.Ordinal), + !string.Equals(reported.Trim(), driverVersion, StringComparison.Ordinal), $"Reported this driver's version '{driverVersion}' instead of the launcher's."); } + // The build identity is the one document produced outside the command + // result path, and it adds a type to the serializer context. Source + // generation has to cover it ahead of time or this returns an empty object. + private static async Task VersionJsonIsStructuredAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["--version", "--json"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + using JsonDocument document = JsonDocument.Parse(fixture.Output.ToString()); + JsonElement root = document.RootElement; + Assert(root.GetProperty("ok").GetBoolean(), "The version document reported failure."); + Assert( + root.GetProperty("command").GetString() == "version", + "The version document did not name the version command."); + Assert( + root.GetProperty("package").GetProperty("version").GetString() == + ClawCtlBuildMetadata.PackageVersion, + "The version document did not carry the baked package version."); + Assert( + root.GetProperty("payload").GetProperty("commit").GetString() == + ClawCtlBuildMetadata.PayloadCommit, + "The version document did not carry the baked payload commit."); + } + private static async Task VersionWinsOverTrailingAsync() { using Fixture fixture = Fixture.CreateWithoutApplication(); @@ -164,9 +200,8 @@ private static async Task VersionWinsOverTrailingAsync() AssertExitCode(0, exitCode, fixture); Assert( - string.Equals( - fixture.Output.ToString().Trim(), - LauncherVersion(), + fixture.Output.ToString().Contains( + ClawCtlBuildMetadata.PackageVersion, StringComparison.Ordinal), "Expected the launcher version with a trailing argument present."); } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index c2a446a5..dce64be8 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.Text.Json; using System.Reflection; namespace OpenClaw.Launcher.Tests; @@ -284,22 +285,134 @@ public async Task PowerShellHelpDescribesTheIsolatedAgentShell() } // The built-in version action reports the entry assembly, which under a test - // host or scenario runner is not the launcher. Assert the launcher's own - // version so that substitution is caught. + // host or scenario runner is not the launcher. Assert the build identity + // compiled into the launcher so that substitution is caught. [Fact] - public async Task VersionReportsTheLauncherAssemblyVersion() + public async Task VersionReportsTheBakedBuildIdentity() { - string expected = typeof(Program).Assembly.GetName().Version?.ToString() - ?? "unknown"; - (int exitCode, string output, string error) = await RunAsync("--version").ConfigureAwait(true); + string reported = Normalize(output); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains(ClawCtlBuildMetadata.PackageVersion, reported, StringComparison.Ordinal); + Assert.Contains(ClawCtlBuildMetadata.PackageCommit, reported, StringComparison.Ordinal); + Assert.Contains(ClawCtlBuildMetadata.PayloadVersion, reported, StringComparison.Ordinal); + Assert.Contains(ClawCtlBuildMetadata.PayloadCommit, reported, StringComparison.Ordinal); + + // The defect this guards: falling back to the library's action, which + // reports whichever assembly started the process. + string? entryVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(); + if (entryVersion is not null) + { + Assert.NotEqual(entryVersion, reported); + } + } + + // --version is satisfied by the version option before command dispatch, so + // it is the one place a JSON document is produced without a command result. + // The documented contract is that every non-interactive command supports + // --json, and silently ignoring it would break a caller that piped it. + [Fact] + public async Task VersionJsonReportsTheBuildIdentity() + { + (int exitCode, string output, string error) = + await RunAsync("--version", "--json").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.DoesNotContain("\u001b", output, StringComparison.Ordinal); + + using JsonDocument document = JsonDocument.Parse(output); + JsonElement root = document.RootElement; + Assert.True(root.GetProperty("ok").GetBoolean()); + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("version", root.GetProperty("command").GetString()); + + JsonElement package = root.GetProperty("package"); + Assert.Equal( + ClawCtlBuildMetadata.PackageVersion, + package.GetProperty("version").GetString()); + Assert.Equal( + ClawCtlBuildMetadata.PackageCommit, + package.GetProperty("commit").GetString()); + + JsonElement payload = root.GetProperty("payload"); + Assert.Equal( + ClawCtlBuildMetadata.PayloadVersion, + payload.GetProperty("version").GetString()); + Assert.Equal( + ClawCtlBuildMetadata.PayloadCommit, + payload.GetProperty("commit").GetString()); + } + + [Theory] + [InlineData("--json=invalid")] + [InlineData("--no-color=invalid")] + public async Task VersionPrecedenceSurvivesMalformedBooleanOptions(string option) + { + (int exitCode, string output, string error) = + await RunAsync("--version", option).ConfigureAwait(true); Assert.Equal(0, exitCode); Assert.Empty(error); - Assert.Equal(expected, output.Trim()); - Assert.NotEqual( - Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), - output.Trim()); + Assert.Contains(ClawCtlBuildMetadata.PackageVersion, output, StringComparison.Ordinal); + Assert.DoesNotContain("This shouldn't happen", output, StringComparison.Ordinal); + } + + [Fact] + public async Task VersionJsonIsAcceptedBeforeTheVersionOption() + { + (int exitCode, string output, _) = + await RunAsync("--json", "--version").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + using JsonDocument document = JsonDocument.Parse(output); + Assert.Equal("version", document.RootElement.GetProperty("command").GetString()); + } + + [Fact] + public async Task VersionPairsEachCommitWithItsVersion() + { + (_, string output, _) = await RunAsync("--version").ConfigureAwait(true); + string reported = Normalize(output); + + Assert.Contains("Package:", reported, StringComparison.Ordinal); + Assert.Contains("Payload:", reported, StringComparison.Ordinal); + + // Each commit belongs to the version it sits behind, so assert the + // pairing rather than the mere presence of four strings. + Assert.Contains( + $"Package: {ClawCtlBuildMetadata.PackageVersion} ({ClawCtlBuildMetadata.PackageCommit})", + reported, + StringComparison.Ordinal); + Assert.Contains( + $"Payload: {ClawCtlBuildMetadata.PayloadVersion} ({ClawCtlBuildMetadata.PayloadCommit})", + reported, + StringComparison.Ordinal); + } + + // The commit is de-emphasised relative to the version it qualifies, so the + // two must not render in the same style. + [Fact] + public void VersionStylesTheCommitApartFromTheVersion() + { + using var colored = new StringWriter(); + ClawCtlConsole.WriteVersion(colored, useColor: true); + string text = colored.ToString(); + + int versionIndex = text.IndexOf( + ClawCtlBuildMetadata.PackageVersion, + StringComparison.Ordinal); + int commitIndex = text.IndexOf( + ClawCtlBuildMetadata.PackageCommit, + StringComparison.Ordinal); + + Assert.True(versionIndex >= 0 && commitIndex > versionIndex); + Assert.Contains( + "\u001b[", + text[versionIndex..commitIndex], + StringComparison.Ordinal); } // The old parser rejected `--version` combined with anything else. The @@ -314,9 +427,11 @@ public async Task VersionWinsOverTrailingArguments() await RunAsync("--version", "bogus").ConfigureAwait(true); Assert.Equal(0, exitCode); - Assert.Equal( - typeof(Program).Assembly.GetName().Version?.ToString(), - output.Trim()); + Assert.Contains( + ClawCtlBuildMetadata.PackageVersion, + Normalize(output), + StringComparison.Ordinal); + Assert.DoesNotContain("bogus", output, StringComparison.Ordinal); } [Theory] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 6b7c0ae7..206aca1a 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -240,9 +240,13 @@ public async Task UnavailableDiagnosticsWarnsOnceAndStillRunsTheCommand() "Unable to create diagnostics", error.ToString(), StringComparison.Ordinal); - Assert.Equal( - typeof(Program).Assembly.GetName().Version?.ToString(), - output.ToString().Trim()); + + // The point of the test is that the command still produced its result + // after diagnostics failed, not what the version happens to be. + Assert.Contains( + ClawCtlBuildMetadata.PackageVersion, + output.ToString(), + StringComparison.Ordinal); } // Startup routes the agent alias without letting the clawctl parser see