diff --git a/.github/workflows/SonarGate.yml b/.github/workflows/SonarGate.yml index 8e343e2..c09e6fc 100644 --- a/.github/workflows/SonarGate.yml +++ b/.github/workflows/SonarGate.yml @@ -17,27 +17,59 @@ jobs: env: PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || 'BSVN_Lmt.Commons' }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} steps: - - name: Wait for the analysis, then check the quality gate + - name: Resolve the PR head commit (manual dispatch) + if: env.PR_HEAD_SHA == '' + env: + GITHUB_TOKEN: ${{ github.token }} shell: pwsh run: | $ErrorActionPreference = 'Stop' + $headers = @{ Authorization = 'Bearer ' + $env:GITHUB_TOKEN; Accept = 'application/vnd.github+json' } + $pull = Invoke-RestMethod -Uri "$($env:GITHUB_API_URL)/repos/$($env:GITHUB_REPOSITORY)/pulls/$($env:PR_NUMBER)" -Headers $headers + "PR_HEAD_SHA=$($pull.head.sha)" | Add-Content -Path $env:GITHUB_ENV + Write-Host "PR head commit: $($pull.head.sha)" - # Poll until SonarCloud publishes the quality-gate result for this PR - # (Automatic Analysis typically completes within a couple of minutes; - # allow up to ~10 minutes for busy moments). - $status = $null - for ($i = 0; $i -lt 60; $i++) { - $uri = 'https://sonarcloud.io/api/qualitygates/project_status?projectKey={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER - $response = Invoke-RestMethod -Uri $uri - if ($response.projectStatus.status -and $response.projectStatus.status -ne 'NONE') { - $status = $response.projectStatus.status + - name: Wait for the analysis of this head commit, then check the quality gate + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + + # The pull_request workflow starts right after the push, while + # SonarCloud Automatic Analysis is still running - and the + # quality-gate API meanwhile serves the PREVIOUS push's result. + # Reporting that stale result against the new commit is a false + # signal, so first wait for SonarCloud's own 'SonarCloud Code + # Analysis' check run of THIS head commit to complete (it appears + # exactly when the analysis finished), and only then read the gate + # status, which now reflects this commit's analysis. + $headers = @{ Authorization = 'Bearer ' + $env:GITHUB_TOKEN; Accept = 'application/vnd.github+json' } + $deadline = (Get-Date).AddMinutes(10) + $sonarCheck = $null + $attempt = 0 + while ((Get-Date) -lt $deadline) { + $attempt++ + $checks = Invoke-RestMethod -Uri "$($env:GITHUB_API_URL)/repos/$($env:GITHUB_REPOSITORY)/commits/$($env:PR_HEAD_SHA)/check-runs?per_page=100" -Headers $headers + $sonarCheck = @($checks.check_runs | Where-Object -FilterScript { $_.name -eq 'SonarCloud Code Analysis' }) + if ($sonarCheck.Count -gt 0 -and -not ($sonarCheck | Where-Object -FilterScript { $_.status -ne 'completed' })) { break } - Write-Host "Waiting for SonarCloud analysis of PR #$($env:PR_NUMBER) (attempt $($i + 1)/60)..." - Start-Sleep -Seconds 10 + Write-Host "Waiting for SonarCloud to analyze commit $($env:PR_HEAD_SHA) (attempt $attempt/40)..." + Start-Sleep -Seconds 15 + } + + if (-not $sonarCheck -or $sonarCheck.Count -eq 0) { + throw "No SonarCloud Code Analysis check run appeared for commit $($env:PR_HEAD_SHA) within 10 minutes. Verify that SonarCloud Automatic Analysis is enabled and the branch is pushed (https://sonarcloud.io)." } + # The analysis of this commit is complete: the gate API now + # reflects it. + $gateUri = 'https://sonarcloud.io/api/qualitygates/project_status?projectKey={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER + $response = Invoke-RestMethod -Uri $gateUri + $status = $response.projectStatus.status $reportUri = 'https://sonarcloud.io/summary/new_code?id={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER switch ($status) { 'OK' { @@ -49,7 +81,7 @@ jobs: exit 1 } default { - Write-Host "::error::No SonarCloud analysis appeared for PR #$($env:PR_NUMBER) within 10 minutes (last status: '$status'). Verify the project at https://sonarcloud.io (Automatic Analysis must be enabled and the branch pushed)." + Write-Host "::error::SonarCloud reported quality-gate status '$status' for PR #$($env:PR_NUMBER) (expected OK or ERROR). Verify the project at https://sonarcloud.io." exit 1 } } diff --git a/AGENTS.md b/AGENTS.md index 286f9a4..91aa8ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,9 +88,17 @@ before: - C# interop code lives in real .NET projects, one per module (`src//.csproj`, netstandard2.0, output to - `src//lib/`), loaded via `RequiredAssemblies` in the manifest. + `src//lib/`). The module loaders load the DLL through the shared + private `Import-BsnInteropAssembly` helper in Bsn.Common (invoked from each + psm1) - deliberately NOT via `RequiredAssemblies`: same-version reimports + (`Import-Module -Force`) reuse the loaded assembly, and cross-version + reimports fail with actionable guidance, because PowerShell cannot host two + versions of the same assembly in one process (issue #15). `build.ps1` compiles every csproj with `dotnet build` before any task - runs; `lib/` and `obj/` are gitignored build outputs. Do not embed C# in + runs, stamping the assembly version from the module manifest + (`-p:Version=`; guard tests assert the equality - all + shipped versions must carry distinct assembly versions); + `lib/` and `obj/` are gitignored build outputs. Do not embed C# in here-strings inside .ps1 files. - Shared helpers are factored into `src/Bsn.Common` (required by the other modules via RequiredModules, the Az/VMware-style dependency chain): @@ -184,11 +192,13 @@ before: actions to full commit SHAs - codecov-action; Dependabot keeps SHA pins updated). - SonarCloud Gate (`.github/workflows/SonarGate.yml`): on every PR to - main, polls the SonarCloud quality-gate API for the PR's new code - (public read API, no token) and fails the check if the gate fails or - no analysis appears within 10 minutes. Make it (plus the CI matrix - and CodeQL checks) a required status check via branch protection to - enforce it before merges. + main, first waits for SonarCloud's own `SonarCloud Code Analysis` check + run of the PR head commit (the workflow starts while Automatic Analysis + is still running, and the quality-gate API would serve the previous + push's stale result), then fails the check unless the quality-gate API + returns OK (public SonarCloud read API, no SonarCloud token). Make it + (plus the CI matrix and CodeQL checks) a required status check via + branch protection to enforce it before merges. - System tests (`src/Bsn.Host/tests/system/`): full end-to-end tests that exercise the real recording engines (no mocks). Windows uses a hidden-console runner process (CI has redirected stdio which breaks diff --git a/CHANGELOG.md b/CHANGELOG.md index c425138..c22f417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,75 @@ All notable changes to the modules in this repository are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the modules adhere to [Semantic Versioning](https://semver.org/). +## [Bsn.Host 0.2.2] - 2026-11-07 + +### Fixed + +- Recordings of byte-writing TUI applications (OpenCode, other + Go/Bubble Tea and Node TUIs, `cmd /c type`) are no longer corrupted + to codepage mojibake: the recorded session now bootstraps the console + codepage to UTF-8 before the command runs (the programmatic + `chcp 65001` via `[Console]::OutputEncoding`/`InputEncoding`). + Root cause: a fresh pseudo console starts with the OEM output + codepage (probe: 437), and OEM-codepage-decoding conhosts decode a + child's stdout BYTES with it - one box-drawing character + (U+2500, UTF-8 `E2 94 80`) arrived as three CP-decoded characters. + pwsh's own output was immune (`WriteConsoleW`), which is why + synthetic pwsh probes passed while real byte-writing TUIs were + corrupted. [#5](https://github.com/BSVN/Lmt.Commons/issues/5) +- Interop assembly loading is now managed by the module loader (via the + shared Bsn.Common helper) instead of the manifest's + `RequiredAssemblies`: re-importing with `Import-Module -Force` reuses + the already-loaded assembly instead of failing with + `FileLoadException`, and importing after `Update-Module` fails with + actionable guidance ("restart the session") instead of a raw + `FileLoadException`. A session restart is still required to switch + assembly versions in one process: PowerShell/.NET cannot host two + versions of the same assembly name, so a same-session update is a + platform limitation, not a module defect. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) +- The interop assembly is now stamped with the module version as its + `AssemblyVersion` at build time (build.ps1 passes + `-p:Version=`, which stays the single + source of truth). Previously every shipped module version carried the + default `AssemblyVersion` 1.0.0.0, so the loader could not tell an + already-loaded old version from the same version, silently reusing + mismatched interop code. Guard tests in Bsn.Host and + Bsn.SystemManagement assert the built DLL version equals the manifest + version. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) + +### Added + +- System tests reproducing each reported corruption scenario against + the real ConPTY engine: the OEM-codepage probe (the recorded session + must report codepage 65001), a raw byte-writing child (Go/Node TUI + output semantics) asserted character-for-character, a real OpenCode + TUI session (when installed), and metacharacter round-trip through + the real engine (issues #5 and #7). + +## [Bsn.SystemManagement 0.2.1] - 2026-11-07 + +### Fixed + +- Same interop assembly loading change as Bsn.Host 0.2.2 (loader-managed + via Bsn.Common 0.1.1, version-stamped assembly). + `Suspend-Computer` behavior is unchanged. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) + +## [Bsn.Common 0.1.1] - 2026-11-07 + +### Added + +- Private `Import-BsnInteropAssembly` helper used by the Bsn.* module + loaders (which pass their module root; the DLL is resolved from + `/lib/.dll`): reuses an already-loaded same-version + interop assembly on `Import-Module -Force`, and fails with actionable + guidance when a different version is already in the session. + Unit tests cover the missing-DLL, fresh-load, same-version reuse and + cross-version guidance paths. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) + ## [Bsn.Host 0.2.1] - 2026-11-07 ### Fixed diff --git a/build.ps1 b/build.ps1 index c177e1c..35a7668 100644 --- a/build.ps1 +++ b/build.ps1 @@ -108,7 +108,22 @@ function Invoke-ModuleCompile { foreach ($project in $projects) { Write-Host ("==> Compiling {0}" -f $project.BaseName) - & $dotnetCommand.Source build $project.FullName --configuration 'Release' --nologo --verbosity 'quiet' + # Stamp the interop assembly with the owning module's manifest version + # (the psd1 stays the single source of truth). Shipping every module + # version with the default AssemblyVersion 1.0.0.0 gives all module + # versions an identical assembly identity, so Import-Module -Force + # after Update-Module fails with FileLoadException (.NET refuses a + # second assembly with the same identity in one process). + # https://github.com/BSVN/Lmt.Commons/issues/15 + $manifestPath = Join-Path -Path $project.DirectoryName -ChildPath ($project.BaseName + '.psd1') + $buildArguments = @('build', $project.FullName, '--configuration', 'Release', '--nologo', '--verbosity', 'quiet') + if (Test-Path -LiteralPath $manifestPath) { + $moduleVersion = (Import-PowerShellDataFile -LiteralPath $manifestPath).ModuleVersion + if ($moduleVersion) { + $buildArguments += ('-p:Version=' + $moduleVersion) + } + } + & $dotnetCommand.Source @buildArguments if ($LASTEXITCODE -ne 0) { throw ("dotnet build failed for '{0}'." -f $project.FullName) } @@ -180,7 +195,22 @@ $taskMap = @{ Import-Module -Name PSScriptAnalyzer -ErrorAction 'Stop' foreach ($module in (Get-BuildModule)) { Write-Host ("==> Analyzing {0}" -f $module.Name) - $findings = @(Invoke-ScriptAnalyzer -Path $module.Path -Recurse -Severity 'Error', 'Warning' -ErrorAction 'Stop') + # PSScriptAnalyzer imports the module it analyzes (that is how the + # test-harness FileLoadException workaround below historically + # appeared). Without the repo src prepended, module names resolve + # to the newest GALLERY copy, so the analysis silently runs against + # published code and leaves that foreign assembly loaded in this + # process - which then breaks the repo tests' interop assembly + # loading (issue #15). Analyze the repo's own modules instead. + $sourceDirectory = Join-Path -Path $repoRoot -ChildPath 'src' + $previousModulePath = $env:PSModulePath + $env:PSModulePath = '{0}{1}{2}' -f $sourceDirectory, [System.IO.Path]::PathSeparator, $env:PSModulePath + try { + $findings = @(Invoke-ScriptAnalyzer -Path $module.Path -Recurse -Severity 'Error', 'Warning' -ErrorAction 'Stop') + } + finally { + $env:PSModulePath = $previousModulePath + } if ($findings.Count -gt 0) { $findings | Format-Table -AutoSize | Out-String -Width 200 | Write-Host -ForegroundColor Yellow throw ('{0} failed PSScriptAnalyzer with {1} finding(s).' -f $module.Name, $findings.Count) diff --git a/src/Bsn.Common/Bsn.Common.psd1 b/src/Bsn.Common/Bsn.Common.psd1 index 8b6467b..205b776 100644 --- a/src/Bsn.Common/Bsn.Common.psd1 +++ b/src/Bsn.Common/Bsn.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Bsn.Common.psm1' - ModuleVersion = '0.1.0' + ModuleVersion = '0.1.1' GUID = '4a2f0a3f-6d21-4c7e-9f42-8b3d1e5c9a76' Author = 'BSVN' CompanyName = 'BSVN' @@ -18,7 +18,7 @@ Tags = @('BSVN', 'Lmt') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Initial release: Get-BsnOperatingSystem with the BsnOperatingSystem enum.' + ReleaseNotes = 'Adds the private Import-BsnInteropAssembly helper used by the Bsn.* module loaders: reuses an already-loaded same-version interop assembly on Import-Module -Force, and fails with actionable guidance when a different version is already in the session (issue #15).' } } } diff --git a/src/Bsn.Common/Bsn.Common.psm1 b/src/Bsn.Common/Bsn.Common.psm1 index d3cc24a..08986b1 100644 --- a/src/Bsn.Common/Bsn.Common.psm1 +++ b/src/Bsn.Common/Bsn.Common.psm1 @@ -65,3 +65,84 @@ function Get-BsnOperatingSystem { return [BsnOperatingSystem]::Unknown } } + +<# +.SYNOPSIS + Loads a module interop assembly with same-session reimport tolerance. + +.DESCRIPTION + PowerShell's assembly loading dedupes by simple assembly name, so a + module that ships its DLL through RequiredAssemblies (or plain + Add-Type) cannot be re-imported from a different installed version in + the same session: the second load fails with FileLoadException + ("Assembly with same name is already loaded"). + + The Bsn.* modules load their interop assemblies through this helper + instead of RequiredAssemblies. The DLL is resolved from the calling + module's own lib directory ('/lib/.dll'): + - when an assembly with the same name AND version is already loaded + (Import-Module -Force of the same version), it is reused silently; + - when a DIFFERENT version is already loaded (Import-Module after + Update-Module), it fails with actionable guidance, because + PowerShell cannot host two versions of the same assembly in one + process - this is why the interop assemblies are stamped with the + module version at build time (build.ps1 passes -p:Version from the + manifest; guard tests assert the equality); + - otherwise the assembly is loaded from the resolved path. + + https://github.com/BSVN/Lmt.Commons/issues/15 + + Kept private (consumer module loaders invoke it through this module's + scope) so it does not extend Bsn.Common's public command surface. +#> +function Import-BsnInteropAssembly { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ModuleRoot, + + [Parameter(Mandatory = $true)] + [string]$AssemblyName + ) + + $assemblyPath = Join-Path -Path (Join-Path -Path $ModuleRoot -ChildPath 'lib') -ChildPath ($AssemblyName + '.dll') + if (-not (Test-Path -LiteralPath $assemblyPath)) { + throw ("The interop assembly is missing at '{0}'. Run './build.ps1' to build it, or reinstall the module." -f $assemblyPath) + } + + $fileVersion = [System.Reflection.AssemblyName]::GetAssemblyName($assemblyPath).Version + $loadedAssemblies = @(Get-BsnLoadedInteropAssembly -AssemblyName $AssemblyName) + if ($loadedAssemblies.Count -gt 0) { + $loadedVersion = $loadedAssemblies[0].GetName().Version + if ($loadedVersion -ne $fileVersion) { + throw ("Cannot load {0} version {1}: version {2} is already loaded in this session (from '{3}'). PowerShell cannot host two versions of the same assembly in one process; restart the session (or open a new one) and import the module again. https://github.com/BSVN/Lmt.Commons/issues/15" -f $AssemblyName, $fileVersion, $loadedVersion, $loadedAssemblies[0].Location) + } + + # Same version already loaded (e.g. Import-Module -Force): reuse it. + return + } + + Add-Type -Path $assemblyPath +} + +<# +.SYNOPSIS + Lists the loaded assemblies with the given simple assembly name. + +.DESCRIPTION + Private seam between Import-BsnInteropAssembly and the AppDomain. + Isolated into a function so unit tests can Mock it (-ModuleName + Bsn.Common) to simulate a differently-versioned assembly already + being loaded - the static AppDomain state itself cannot be faked. +#> +function Get-BsnLoadedInteropAssembly { + [CmdletBinding()] + [OutputType('System.Reflection.Assembly')] + param( + [Parameter(Mandatory = $true)] + [string]$AssemblyName + ) + + @([System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq $AssemblyName }) +} diff --git a/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 b/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 index dc6acee..02f7655 100644 --- a/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 +++ b/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 @@ -35,3 +35,75 @@ Describe 'Get-BsnOperatingSystem' { } } } + +Describe 'Import-BsnInteropAssembly (private interop loader)' { + # https://github.com/BSVN/Lmt.Commons/issues/15 + # The loader is private: every test drives it through the defining + # module's own scope, which is exactly how the consumer module loaders + # invoke it. + BeforeAll { + $script:CommonModule = Get-Module -Name 'Bsn.Common' + # build.ps1 compiles the sibling interop projects before any task + # runs, so the real Bsn.Host DLL is available for load-path tests. + $script:HostModuleRoot = Join-Path -Path (Split-Path -Parent -Path $moduleRoot) -ChildPath 'Bsn.Host' + $script:HostDllPath = Join-Path -Path (Join-Path -Path $script:HostModuleRoot -ChildPath 'lib') -ChildPath 'Bsn.Host.dll' + } + + It 'throws with actionable guidance when the interop assembly is missing' { + { + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Missing' } $TestDrive + } | Should -Throw '*is missing at*Bsn.Missing.dll*to build it, or reinstall the module*' + } + + It 'loads a fresh interop assembly through Add-Type' { + if (-not (Test-Path -LiteralPath $script:HostDllPath)) { + Set-ItResult -Skipped -Because 'requires the built Bsn.Host interop DLL (run ./build.ps1 first).' + } + + # Simulate an empty session (nothing loaded yet) so the fresh-load + # path is exercised deterministically even when an earlier task in + # this process already imported Bsn.Host; the actual load is mocked + # to keep the test process clean. + Mock -ModuleName 'Bsn.Common' Get-BsnLoadedInteropAssembly { @() } + Mock -ModuleName 'Bsn.Common' Add-Type { } + + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + + Should -Invoke -CommandName 'Get-BsnLoadedInteropAssembly' -ModuleName 'Bsn.Common' -Times 1 -Exactly -Scope It -ParameterFilter { $AssemblyName -eq 'Bsn.Host' } + Should -Invoke -CommandName 'Add-Type' -ModuleName 'Bsn.Common' -Times 1 -Exactly -Scope It -ParameterFilter { $Path -like '*Bsn.Host.dll' } + } + + It 'reuses an already-loaded same-version assembly silently' { + if (-not (Test-Path -LiteralPath $script:HostDllPath)) { + Set-ItResult -Skipped -Because 'requires the built Bsn.Host interop DLL (run ./build.ps1 first).' + } + + # First call loads (or reuses) the real assembly; the second call + # takes the same-version reuse path (Import-Module -Force scenario) + # and must succeed without any error. + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + { + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + } | Should -Not -Throw + } + + It 'fails with actionable guidance when a different version is already loaded' { + if (-not (Test-Path -LiteralPath $script:HostDllPath)) { + Set-ItResult -Skipped -Because 'requires the built Bsn.Host interop DLL (run ./build.ps1 first).' + } + + # The Update-Module scenario: a DIFFERENT version of the assembly is + # already in the session. The static AppDomain cannot be faked + # directly, so the lookup seam is mocked with a fake assembly object + # whose version differs from the DLL on disk. + Mock -ModuleName 'Bsn.Common' Get-BsnLoadedInteropAssembly { + $loadedFake = [PSCustomObject]@{ Location = 'C:\Program Files\PowerShell\Modules\Bsn.Host\0.0.1\lib\Bsn.Host.dll' } + Add-Member -InputObject $loadedFake -MemberType 'ScriptMethod' -Name 'GetName' -Value { [PSCustomObject]@{ Version = [version]'1.0.0.0' } } + @($loadedFake) + } + + { + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + } | Should -Throw '*Cannot load Bsn.Host version*version 1.0.0.0 is already loaded in this session*restart the session*issues/15' + } +} diff --git a/src/Bsn.Host/Bsn.Host.psd1 b/src/Bsn.Host/Bsn.Host.psd1 index 1caf669..9b89f24 100644 --- a/src/Bsn.Host/Bsn.Host.psd1 +++ b/src/Bsn.Host/Bsn.Host.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Bsn.Host.psm1' - ModuleVersion = '0.2.1' + ModuleVersion = '0.2.2' GUID = '9d84a7b8-1f2b-4d60-a4bd-3d7ef1d9f337' Author = 'BSVN' CompanyName = 'BSVN' @@ -9,9 +9,8 @@ PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop', 'Core') RequiredModules = @( - @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.0' } + @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.1' } ) - RequiredAssemblies = @('lib/Bsn.Host.dll') FunctionsToExport = @('Start-InteractiveTranscript', 'Stop-InteractiveTranscript', 'Show-InteractiveTranscript') CmdletsToExport = @() VariablesToExport = @() @@ -22,7 +21,7 @@ Tags = @('BSVN', 'Lmt', 'Host', 'Transcript', 'Recording', 'Terminal', 'Session', 'Asciicast', 'Asciinema', 'TUI', 'Audit', 'Screencast') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Bug fix release: multi-byte UTF-8 survives chunk boundaries during recording; cast files stay valid when the recording process dies (incremental flush); -Command values round-trip safely through pwsh -EncodedCommand instead of cmd.exe; recording from a host with both stdin and stdout redirected is now a terminating error instead of an empty cast. Fixes issues #5, #6, #7 and #8.' + ReleaseNotes = 'Bug fix release: recordings of byte-writing TUI applications (OpenCode and other Go/Node TUIs) are no longer corrupted to codepage mojibake - the recorded session bootstraps the console codepage to UTF-8; interop assembly loading is loader-managed (Import-Module -Force reuses the loaded assembly; cross-version imports get clear guidance and a session restart is required to switch versions). Also includes the 0.2.1 fixes: multi-byte UTF-8 survives chunk boundaries; cast files stay valid when the recording process dies; -Command values round-trip safely through pwsh -EncodedCommand; recording with both stdin and stdout redirected is a terminating error. Fixes issues #5, #6, #7, #8 and #15.' } } } diff --git a/src/Bsn.Host/Bsn.Host.psm1 b/src/Bsn.Host/Bsn.Host.psm1 index 7afe9a6..001a3ce 100644 --- a/src/Bsn.Host/Bsn.Host.psm1 +++ b/src/Bsn.Host/Bsn.Host.psm1 @@ -50,3 +50,16 @@ $functionFiles = Get-ChildItem -Path $functionSearchPaths -Filter '*.ps1' -Recur foreach ($functionFile in $functionFiles) { . $functionFile.FullName } + + +# Load the interop assembly through Bsn.Common's private loader instead of the +# manifest's RequiredAssemblies (issue #15): a same-version reimport +# (Import-Module -Force) reuses the loaded assembly, and a cross-version +# reimport (after Update-Module) fails with actionable guidance instead of a +# raw FileLoadException. Pick the NEWEST loaded Bsn.Common: after an update +# the session may hold the old gallery copy and the newly required version +# side by side, and invoking an array of module infos would stringify into a +# bogus command. The engine types resolve at call time, so loading after the +# function files are dot-sourced matches the RequiredAssemblies timing. +$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 +& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.Host' diff --git a/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 b/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 index 4370366..c8d07e5 100644 --- a/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 +++ b/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 @@ -9,10 +9,26 @@ recorded command (issue #7): the exact command text is preserved bit-for-bit through the encoding. + The wrapper bootstraps the console codepage to UTF-8 before the + recorded command runs (issue #5, second root cause): conhost decodes + a child's stdout BYTES using the console output codepage, and a fresh + pseudo console starts with the OEM codepage (e.g. 437), not UTF-8. + TUIs that write UTF-8 bytes directly to the console handle (Go/Bubble + Tea applications such as OpenCode, Node raw writes, 'cmd /c type') are + then mangled before our recorder sees a single byte - every box-drawing + character becomes three CP-decoded characters (e.g. U+2500 'E2 94 80' + becomes 'Gamma-ohmega-acute' in CP437). pwsh's own output is immune + (it writes through WriteConsoleW), which is why synthetic pwsh probes + passed while real byte-writing TUIs produced mojibake. Setting + [Console]::OutputEncoding / InputEncoding to UTF-8 is the programmatic + equivalent of 'chcp 65001' and fixes the decoding for the whole + recorded session, including applications started later inside it. + Without -Command, the default interactive shell is returned; when that - shell is pwsh or powershell, Bsn.Host is auto-imported into the nested - session so Stop-InteractiveTranscript is available inside the recording, - and the recording-aware prompt is installed when requested. + shell is pwsh or powershell, the same codepage bootstrap runs first, + Bsn.Host is auto-imported into the nested session so + Stop-InteractiveTranscript is available inside the recording, and the + recording-aware prompt is installed when requested. #> function Get-BsnRecordingCommandLine { [CmdletBinding()] @@ -23,6 +39,10 @@ function Get-BsnRecordingCommandLine { [switch]$ShowRecordingIndicator ) + # ASCII-only bootstrap (this file stays ASCII per repo policy): UTF-8 + # console codepage for the recorded session (issue #5, conhost CP decode). + $codePageBootstrap = '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8;' + if ($Command) { # Base64-encode the command to survive the CreateProcess command line # without any shell metacharacter interpretation (issue #7). @@ -30,7 +50,8 @@ function Get-BsnRecordingCommandLine { if ($shellPath -match ' ') { $shellPath = '"{0}"' -f $shellPath } - $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($Command)) + $wrappedCommand = $codePageBootstrap + ' ' + $Command + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wrappedCommand)) return ('{0} -NoProfile -EncodedCommand {1}' -f $shellPath, $encoded) } @@ -40,14 +61,17 @@ function Get-BsnRecordingCommandLine { } if ($shellPath -match '(pwsh|powershell)(\.exe)?("?)$') { - $nestedCommand = 'Import-Module Bsn.Host' + $nestedCommand = $codePageBootstrap + ' Import-Module Bsn.Host' if ($ShowRecordingIndicator.IsPresent) { # Recording-aware prompt for the nested session; the glyph is built from # its code point so this file stays ASCII-only. - $nestedCommand = 'Import-Module Bsn.Host; function prompt { ''{0} REC PS {1}> '' -f [char]0x23FA, (Get-Location).Path }' + $nestedCommand = $codePageBootstrap + ' Import-Module Bsn.Host; function prompt { ''{0} REC PS {1}> '' -f [char]0x23FA, (Get-Location).Path }' } return ('{0} -NoLogo -NoExit -Command "{1}"' -f $shellPath, $nestedCommand) } + # Non-PowerShell default shells cannot run the pwsh bootstrap inline; the + # console keeps its OEM codepage (byte-writing TUIs need 'chcp 65001' run + # manually inside them on OEM-codepage-decoding conhosts). return $shellPath } diff --git a/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 b/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 index 7ed1b2e..dcd0898 100644 --- a/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 +++ b/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Thin wrapper around the compiled Bsn.Host engine (src/Bsn.Host.csproj, - loaded via the module manifest's RequiredAssemblies): + loaded through the shared Bsn.Common loader instead of RequiredAssemblies): - creates a pseudo-console and starts the command attached to it, - proxies raw console input into the pseudo-console, - tees the VT output stream to the real console and, with timestamps, diff --git a/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 b/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 index 6c3785b..3e79216 100644 --- a/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 +++ b/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 @@ -643,6 +643,175 @@ echo 'VIM-TUI-DONE' } } + Context 'Issue #5: console codepage bootstrap (byte-writing TUIs such as OpenCode)' { + # https://github.com/BSVN/Lmt.Commons/issues/5 + # Second root cause of the reported recording corruption: a fresh + # pseudo console starts with the OEM output codepage (e.g. 437 - the + # probe 'chcp.com' proves it), and OEM-codepage-decoding conhosts + # decode a child's stdout BYTES with that codepage. Byte-writing TUIs + # (Go/Bubble Tea applications such as OpenCode, Node raw writes, + # 'cmd /c type') emit UTF-8 bytes, so every multi-byte character is + # mangled BEFORE the recorder sees a single byte: one box-drawing + # character (U+2500, UTF-8 E2 94 80) arrives as three CP-decoded + # characters (CP437 'Gamma ohmega-acute'). pwsh's own output is + # immune (it writes through WriteConsoleW), which is why synthetic + # pwsh probes passed while real byte-writing TUIs were corrupted. + # The wrapper therefore bootstraps [Console]::OutputEncoding / + # InputEncoding to UTF-8 (the programmatic 'chcp 65001') before the + # recorded command runs. + + It 'records a session whose console codepage is UTF-8 (65001)' { + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform) { + Set-ItResult -Skipped -Because 'the codepage bootstrap is a ConPTY (Windows engine) behavior.' + } + + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-chcp.ps1' + 'chcp.com' | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $castPath = Join-Path -Path $TestDrive -ChildPath 'codepage.cast' + $result = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath + $result.ExitCode | Should -Be 0 + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $rawStream.Contains('65001') | Should -BeTrue -Because ('the recorded session must bootstrap the console codepage to UTF-8 (conhost decodes child output bytes with it; a fresh pseudo console starts at the OEM codepage); the recorded chcp output was: ' + ($rawStream -replace '\u001b', '^[')) + } + + It 'records a BYTE-WRITING child (raw console-handle output, Go/Node TUI style) without corruption' { + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform) { + Set-ItResult -Skipped -Because 'the codepage bootstrap is a ConPTY (Windows engine) behavior.' + } + + # The probe file holds UTF-8 bytes for box-drawing borders plus an + # ASCII marker - exactly the payload shape of a byte-writing TUI. + $probePath = Join-Path -Path $TestDrive -ChildPath 'probe-utf8.bin' + $box = [string][char]0x2500 + $probeText = 'START-MARKER' + "`r`n" + ` + ([string][char]0x256D) + ($box * 30) + ([string][char]0x256E) + "`r`n" + ` + ([string][char]0x2502) + ' SALAM-MARKER ' + ([string][char]0x2502) + "`r`n" + ` + ([string][char]0x2570) + ($box * 30) + ([string][char]0x256F) + "`r`n" + ` + 'END-MARKER' + [System.IO.File]::WriteAllBytes($probePath, [System.Text.Encoding]::UTF8.GetBytes($probeText)) + + # The scenario writes the file's RAW BYTES straight to the console + # output handle (no WriteConsoleW) - byte-writer semantics. + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-bytewriter.ps1' + @' +$bytes = [System.IO.File]::ReadAllBytes($env:BsnSystemTestProbeFile) +$stdout = [Console]::OpenStandardOutput() +$stdout.Write($bytes, 0, $bytes.Length) +$stdout.Flush() +'@ | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $env:BsnSystemTestProbeFile = $probePath + try { + $castPath = Join-Path -Path $TestDrive -ChildPath 'bytewriter.cast' + $result = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath + $result.ExitCode | Should -Be 0 + } + finally { + Remove-Item 'Env:BsnSystemTestProbeFile' -ErrorAction 'SilentlyContinue' + } + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $countOf = { param($codePoint) ([regex]::Matches($rawStream, [regex]::Escape([string][char]$codePoint))).Count } + + (& $countOf 0x256D) | Should -Be 1 -Because 'the top-left rounded corner must survive (mangling turns one box character into several CP-decoded characters)' + (& $countOf 0x2500) | Should -Be 60 -Because 'all sixty horizontal border characters must survive byte-for-byte' + (& $countOf 0x256E) | Should -Be 1 -Because 'the top-right rounded corner must survive' + (& $countOf 0x2570) | Should -Be 1 -Because 'the bottom-left rounded corner must survive' + (& $countOf 0x256F) | Should -Be 1 -Because 'the bottom-right rounded corner must survive' + $rawStream.Contains('SALAM-MARKER') | Should -BeTrue -Because 'the ASCII payload must be captured' + + $replacementCount = ([regex]::Matches($rawStream, [regex]::Escape([string][char]0xFFFD))).Count + $replacementCount | Should -Be 0 -Because 'no chunk-boundary U+FFFD corruption is allowed (issue #5 decoder fix)' + $mojibakeCount = ([regex]::Matches($rawStream, '[\u00C0-\u00FF]')).Count + $mojibakeCount | Should -Be 0 -Because ('no codepage-decoded mojibake characters are allowed; found {0} Latin-1-range characters, the signature of conhost decoding UTF-8 bytes with the OEM codepage (issue #5)' -f $mojibakeCount) + } + + It 'records a real OpenCode TUI session without corruption' -Tag 'RequiresOpenCode' { + # https://github.com/BSVN/Lmt.Commons/issues/5 + # The exact scenario from the field report: OpenCode renders its + # full-screen TUI (Bubble Tea/Go) by writing UTF-8 bytes directly + # to the console handle. On OEM-codepage-decoding conhosts this + # corrupted every border in the recording. + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform) { + Set-ItResult -Skipped -Because 'OpenCode TUI recording test targets the Windows ConPTY engine.' + } + if (-not (Get-Command -Name 'opencode' -ErrorAction 'SilentlyContinue')) { + Set-ItResult -Skipped -Because 'opencode is not on PATH (install it to run this real-world TUI regression test).' + } + + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-opencode.ps1' + @' +$env:TERM = 'xterm-256color' +opencode +'@ | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $castPath = Join-Path -Path $TestDrive -ChildPath 'opencode.cast' + $null = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath -StopAfterDelay + Test-Path -LiteralPath $castPath | Should -BeTrue -Because 'the OpenCode session must produce a cast' + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $rawStream.Length | Should -BeGreaterThan 1000 -Because 'the OpenCode TUI renders substantial output' + + $replacementCount = ([regex]::Matches($rawStream, [regex]::Escape([string][char]0xFFFD))).Count + $replacementCount | Should -Be 0 -Because 'the OpenCode TUI recording must contain zero U+FFFD replacement characters' + $mojibakeCount = ([regex]::Matches($rawStream, '[\u00C0-\u00FF]')).Count + $mojibakeCount | Should -Be 0 -Because ('the OpenCode TUI recording must contain zero codepage mojibake characters (found {0}); UTF-8 box-drawing borders must survive the console codepage bootstrap (issue #5)' -f $mojibakeCount) + + $hasKnownUiText = $rawStream.Contains('New session') -or $rawStream.Contains('Ask anything') -or $rawStream.Contains('Build') + $hasKnownUiText | Should -BeTrue -Because 'the OpenCode UI text must be captured (proves the recorded session is the TUI)' + } + } + + Context 'Issue #7: metacharacters survive a REAL ConPTY recording' { + # https://github.com/BSVN/Lmt.Commons/issues/7 + # The unit tests prove -EncodedCommand preserves the command text; this + # system test proves it END TO END through the real engine: the recorded + # output must contain the metacharacter payload verbatim. + It 'records -Command values with pipes, ampersands and redirections exactly' { + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform -and ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected)) { + Set-ItResult -Skipped -Because 'the Unix script(1) engine is unreliable under CI redirected stdio; run locally.' + } + + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-metachar.ps1' + @' +Write-Output 'METACHAR:a|b&ce^f' +'@ | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $castPath = Join-Path -Path $TestDrive -ChildPath 'metachar.cast' + $result = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath + $result.ExitCode | Should -Be 0 + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $rawStream.Contains('METACHAR:a|b&ce^f') | Should -BeTrue -Because 'pipes, ampersands, redirections and carets must survive the recorded command line verbatim (issue #7)' + } + } + Context 'session with cmdlets and a TUI application' { It 'records the session and the cast matches the real view' { if (-not $script:CanRunSystemTests) { diff --git a/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 b/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 index f7bef82..8f48666 100644 --- a/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 +++ b/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 @@ -23,6 +23,16 @@ Describe 'Bsn.Host manifest' { $manifest = Test-ModuleManifest -Path $script:ManifestPath ($manifest.ExportedFunctions.Keys | Sort-Object) -join ',' | Should -Be 'Show-InteractiveTranscript,Start-InteractiveTranscript,Stop-InteractiveTranscript' } + + It 'stamps the interop assembly with the manifest version (issue #15)' { + # With the default AssemblyVersion 1.0.0.0 in every release the loader + # could not tell versions apart: Import-Module -Force after + # Update-Module failed with FileLoadException (issue #15). + $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib/Bsn.Host.dll' + Test-Path -LiteralPath $dllPath | Should -BeTrue + [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version.ToString(3) | + Should -Be (Test-ModuleManifest -Path $script:ManifestPath).Version.ToString() + } } Describe 'Bsn.Host module' { diff --git a/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 b/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 index a7d4476..4d2dcd8 100644 --- a/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 +++ b/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 @@ -266,6 +266,42 @@ Describe 'Start-InteractiveTranscript' { $commandLine | Should -BeLike '*-EncodedCommand*' } + It 'bootstraps the console codepage to UTF-8 in the recorded command session (issue #5)' { + # https://github.com/BSVN/Lmt.Commons/issues/5 + # Byte-writing TUIs (Go/Bubble Tea such as OpenCode) emit UTF-8 + # bytes that OEM-codepage-decoding conhosts mangle; the recorded + # session must set the console codepage to UTF-8 before the + # command runs (the programmatic 'chcp 65001'). The command is + # always wrapped in pwsh, so this holds on every platform. + $commandLine = & (Get-Module -Name 'Bsn.Host') { param($c) Get-BsnRecordingCommandLine -Command $c } 'echo hello' + $encoded = [regex]::Match($commandLine, '-EncodedCommand ([A-Za-z0-9+/=]+)').Groups[1].Value + $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $decoded.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue + $decoded.Contains('[Console]::InputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue + $decoded.Contains('echo hello') | Should -BeTrue + } + + It 'bootstraps UTF-8 and auto-imports Bsn.Host in a PowerShell interactive session (issue #5)' { + # The interactive shell is resolved per platform (pwsh/powershell + # on Windows, $env:SHELL on Unix); mock it so the PowerShell + # branch is exercised deterministically on every OS. + Mock -CommandName Get-BsnDefaultShell -ModuleName 'Bsn.Host' { 'C:\Program Files\PowerShell\7\pwsh.exe' } + + $interactiveLine = & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine } + $interactiveLine.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue + $interactiveLine.Contains('Import-Module Bsn.Host') | Should -BeTrue + $interactiveLine -like '*-NoLogo -NoExit*' | Should -BeTrue + } + + It 'returns the bare shell path when the interactive shell is not PowerShell' { + # Unix terminals are natively UTF-8 - the codepage bootstrap is a + # Windows conhost concern - so a non-PowerShell default shell is + # started as-is. + Mock -CommandName Get-BsnDefaultShell -ModuleName 'Bsn.Host' { '/bin/bash' } + + & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine } | Should -Be '/bin/bash' + } + It 'preserves shell metacharacters through Base64 round-trip' -TestCases @( @{ Original = "Write-Output A | Write-Host -NoNewline 'got: '" } @{ Original = 'Write-Output "hello | world" & more' } @@ -277,7 +313,10 @@ Describe 'Start-InteractiveTranscript' { if ($commandLine -match '-EncodedCommand\s+([A-Za-z0-9+/=]+)') { $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($Matches[1])) - $decoded | Should -Be $Original + # The wrapper prefixes the UTF-8 codepage bootstrap (issue #5); + # the user's command must follow it verbatim. + $decoded.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; ') | Should -BeTrue + $decoded.EndsWith($Original) | Should -BeTrue } else { throw "Expected -EncodedCommand in command line but got: $commandLine" diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 index b0fc5c8..fbf83b9 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Bsn.SystemManagement.psm1' - ModuleVersion = '0.2.0' + ModuleVersion = '0.2.1' GUID = 'b7d64e3a-2f4e-4f8a-9a1d-3c5e7a9b1f02' Author = 'BSVN' CompanyName = 'BSVN' @@ -9,9 +9,8 @@ PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop', 'Core') RequiredModules = @( - @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.0' } + @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.1' } ) - RequiredAssemblies = @('lib/Bsn.SystemManagement.dll') FunctionsToExport = @('Suspend-Computer') CmdletsToExport = @() VariablesToExport = @() @@ -22,7 +21,7 @@ Tags = @('System', 'Management', 'Power', 'Sleep', 'Hibernate', 'Suspend', 'CrossPlatform', 'Windows', 'Linux', 'macOS', 'BSVN', 'Lmt') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Suspend-Computer now uses the compiled Bsn.SystemManagement interop assembly locally (self-contained action script remains for remoting) and depends on the shared Bsn.Common module. See https://github.com/BSVN/Lmt.Commons/blob/main/CHANGELOG.md.' + ReleaseNotes = 'Interop assembly loading is now managed by the module loader (via Bsn.Common 0.1.1): re-importing with Import-Module -Force reuses the already-loaded assembly, and importing after Update-Module fails with clear guidance instead of a raw FileLoadException (a session restart is still required to switch assembly versions). The interop assembly now carries the module version as its assembly version. Suspend-Computer behavior is unchanged. See https://github.com/BSVN/Lmt.Commons/blob/main/CHANGELOG.md and issue #15.' } } } diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 index f7bf0c9..9616c15 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 @@ -44,3 +44,17 @@ $functionFiles = Get-ChildItem -Path $functionSearchPaths -Filter '*.ps1' -Recur foreach ($functionFile in $functionFiles) { . $functionFile.FullName } + + +# Delegate the interop assembly load to Bsn.Common's private helper (issue +# #15): reimporting the SAME release reuses what the session already hosts, +# while importing a NEWER release after Update-Module stops with explicit +# guidance, because one process cannot host one assembly under two versions. +# The helper is invoked through the newest loaded Bsn.Common instance (old and +# new copies coexist after an update; a bare array would stringify into a +# bogus command). Interop types bind at call time, so the late placement is +# equivalent to the manifest's RequiredAssemblies timing. +$systemInteropHost = Get-Module -Name 'Bsn.Common' | + Sort-Object -Property Version -Descending | + Select-Object -First 1 +& $systemInteropHost { param($moduleDirectory, $assemblySimpleName) Import-BsnInteropAssembly -ModuleRoot $moduleDirectory -AssemblyName $assemblySimpleName } $script:ModuleRoot 'Bsn.SystemManagement' diff --git a/src/Bsn.SystemManagement/tests/Module.Tests.ps1 b/src/Bsn.SystemManagement/tests/Module.Tests.ps1 index 2b0cb66..617cb3d 100644 --- a/src/Bsn.SystemManagement/tests/Module.Tests.ps1 +++ b/src/Bsn.SystemManagement/tests/Module.Tests.ps1 @@ -15,6 +15,16 @@ Describe 'Bsn.SystemManagement manifest' { $manifest = Test-ModuleManifest -Path $script:ManifestPath ($manifest.ExportedFunctions.Keys | Sort-Object) -join ',' | Should -Be 'Suspend-Computer' } + + It 'stamps the interop assembly with the manifest version (issue #15)' { + # Same guard as Bsn.Host (issue #15): the DLL's AssemblyVersion must + # follow the manifest, otherwise the loader's version checks cannot + # distinguish releases. + $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib/Bsn.SystemManagement.dll' + Test-Path -LiteralPath $dllPath | Should -BeTrue + [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version.ToString(3) | + Should -Be (Test-ModuleManifest -Path $script:ManifestPath).Version.ToString() + } } Describe 'Bsn.SystemManagement module' {