diff --git a/CHANGELOG.md b/CHANGELOG.md index e652173..760f0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## Script-context helper restoration (unreleased) + +- Restore Get-ScriptDirectory and Get-ScriptName; export twenty-nine commands. +- Resolve the immediately calling script rather than module-scoped invocation data. +- Support explicit literal filesystem paths and pipeline input without requiring existence. +- Return no output for interactive calls without a script path. +- Remove dependence on the externally supplied hostinvocation variable. +- Add real script, nested/dot-sourced caller and isolated interactive regression tests. + ## AD helper restoration (unreleased) - Restore Test-IsValidDn, Test-IsValidUpn and Get-ReportChain; export twenty-seven commands. diff --git a/IT-ToolBox.psd1 b/IT-ToolBox.psd1 index 5990b7f..9d61882 100644 --- a/IT-ToolBox.psd1 +++ b/IT-ToolBox.psd1 @@ -14,6 +14,8 @@ 'Get-TimerStatus' 'Stop-Timer' 'Get-ElapsedTime' + 'Get-ScriptDirectory' + 'Get-ScriptName' 'Test-FileName' 'Test-IsValidPath' 'Test-IsIP' diff --git a/IT-ToolBox.psm1 b/IT-ToolBox.psm1 index 8e28cd5..99eeb1c 100644 --- a/IT-ToolBox.psm1 +++ b/IT-ToolBox.psm1 @@ -14,6 +14,8 @@ Export-ModuleMember -Function @( 'Get-TimerStatus' 'Stop-Timer' 'Get-ElapsedTime' + 'Get-ScriptDirectory' + 'Get-ScriptName' 'Test-FileName' 'Test-IsValidPath' 'Test-IsIP' diff --git a/Legacy/Get-ScriptDirectory.ps1 b/Legacy/Get-ScriptDirectory.ps1 deleted file mode 100644 index ae40c52..0000000 --- a/Legacy/Get-ScriptDirectory.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -function Get-ScriptDirectory -{ -<# - .SYNOPSIS - Get-ScriptDirectory returns the proper location of the script. - - .OUTPUTS - System.String -#> - [OutputType([string])] - param () - if ($null -ne $hostinvocation) - { - Split-Path $hostinvocation.MyCommand.path - } - else - { - Split-Path $script:MyInvocation.MyCommand.Path - } -} \ No newline at end of file diff --git a/Legacy/Get-ScriptName.ps1 b/Legacy/Get-ScriptName.ps1 deleted file mode 100644 index 3b2f94b..0000000 --- a/Legacy/Get-ScriptName.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -function Get-ScriptName -{ -<# - .SYNOPSIS - Get-ScriptName returns the name of the script. - - .OUTPUTS - System.String -#> - [OutputType([string])] - param () - if ($null -ne $hostinvocation) - { - $hostinvocation.MyCommand.Name - } - else - { - $script:MyInvocation.MyCommand.Name - } -} \ No newline at end of file diff --git a/Legacy/README.md b/Legacy/README.md index f9f8a1a..6f85d05 100644 --- a/Legacy/README.md +++ b/Legacy/README.md @@ -1,6 +1,8 @@ # Historical commands -These six commands are preserved for reference and are not loaded or exported by v3. +These four commands are preserved for reference and are not loaded or exported by v3. The string encryption design is unsuitable for new security implementations. -Exchange helpers require a separate compatibility review. Script-context helpers -require clarification of caller semantics before reuse. +Exchange helpers require a separate compatibility review. + +Get-ScriptDirectory and Get-ScriptName now have supported implementations in Public/. +See the root README for caller resolution and interactive behavior. diff --git a/Private/Resolve-ITToolBoxScriptPath.ps1 b/Private/Resolve-ITToolBoxScriptPath.ps1 new file mode 100644 index 0000000..f33252e --- /dev/null +++ b/Private/Resolve-ITToolBoxScriptPath.ps1 @@ -0,0 +1,26 @@ +function Resolve-ITToolBoxScriptPath { + [CmdletBinding()] + [OutputType([string])] + param ( + [AllowEmptyString()] + [string]$ScriptPath, + [AllowEmptyString()] + [string]$CallerScriptPath + ) + + $path = if ($ScriptPath) { $ScriptPath } else { $CallerScriptPath } + if ([string]::IsNullOrEmpty($path)) { return } + + $provider = $null + $drive = $null + $resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $path, [ref]$provider, [ref]$drive + ) + if ($provider.Name -ne 'FileSystem') { + throw 'ScriptPath must be a filesystem path.' + } + if ([string]::IsNullOrEmpty([System.IO.Path]::GetFileName($resolved))) { + throw 'ScriptPath must include a filename.' + } + $resolved +} diff --git a/Public/Get-ScriptDirectory.ps1 b/Public/Get-ScriptDirectory.ps1 new file mode 100644 index 0000000..deb00e1 --- /dev/null +++ b/Public/Get-ScriptDirectory.ps1 @@ -0,0 +1,29 @@ +function Get-ScriptDirectory { + <# + .SYNOPSIS + Return the directory of the calling script or an explicit script path. + .DESCRIPTION + Uses the immediately calling script, including dot-sourced scripts and functions + defined in a script. Interactive calls without ScriptPath produce no output. + Explicit paths are literal filesystem paths, resolved against the current location; + the target need not exist. No hostinvocation variable is required. + .PARAMETER ScriptPath + Optional explicit script filename, with an absolute or relative filesystem path. + .EXAMPLE + Get-ScriptDirectory + .EXAMPLE + Get-ScriptDirectory -ScriptPath './scripts/automation.ps1' + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [string]$ScriptPath + ) + + process { + $path = Resolve-ITToolBoxScriptPath -ScriptPath $ScriptPath -CallerScriptPath $MyInvocation.ScriptName + if ($path) { [System.IO.Path]::GetDirectoryName($path) } + } +} diff --git a/Public/Get-ScriptName.ps1 b/Public/Get-ScriptName.ps1 new file mode 100644 index 0000000..fee379e --- /dev/null +++ b/Public/Get-ScriptName.ps1 @@ -0,0 +1,29 @@ +function Get-ScriptName { + <# + .SYNOPSIS + Return the filename (including its extension) of the calling script or an explicit script path. + .DESCRIPTION + Uses the immediately calling script, including dot-sourced scripts and functions + defined in a script. Interactive calls without ScriptPath produce no output. + Explicit paths are literal filesystem paths, resolved against the current location; + the target need not exist. No hostinvocation variable is required. + .PARAMETER ScriptPath + Optional explicit script filename, with an absolute or relative filesystem path. + .EXAMPLE + Get-ScriptName + .EXAMPLE + Get-ScriptName -ScriptPath './scripts/automation.ps1' + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [string]$ScriptPath + ) + + process { + $path = Resolve-ITToolBoxScriptPath -ScriptPath $ScriptPath -CallerScriptPath $MyInvocation.ScriptName + if ($path) { [System.IO.Path]::GetFileName($path) } + } +} diff --git a/README.md b/README.md index ffd2367..fe04b95 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ The foundation imports without WinSCP, GnuPG, Active Directory or Exchange depen | Get-TimerStatus | Check whether a Stopwatch is running | | Stop-Timer | Stop a Stopwatch | | Get-ElapsedTime | Retrieve elapsed time or individual components | +| Get-ScriptDirectory | Directory of the calling script or an explicit script path | +| Get-ScriptName | Filename of the calling script or an explicit script path | | Test-FileName | Native filename validation; optional Windows-compatible rules | | Test-IsValidPath | Native filesystem path syntax; no existence check | | Test-IsIP | Standard IPv4/IPv6 literals; strict dotted IPv4 | @@ -66,10 +68,10 @@ Redaction is opt-in and does not guarantee detection of every secret. - SCP and GnuPG wrappers and bundled WinSCP binaries are removed. Separate modules will own file transfer and OpenPGP; no replacement is bundled here. -- `Legacy/` retains string encryption, Exchange and script-context helpers for reference. +- `Legacy/` retains string encryption and Exchange helpers for reference. - All commands formerly retained in `Staging/v3/` now have supported implementations. Its README records the migration; separate legacy/staged integrations remain excluded. They are not currently exported. -- Only the twenty-seven listed commands are exported. Private helpers, variables and aliases +- Only the twenty-nine listed commands are exported. Private helpers, variables and aliases are not exported. Existing calls to other v2 commands require the v2 release until those commands return to the supported API. - The module GUID and Git history are preserved. @@ -373,3 +375,27 @@ No ActiveDirectory dependency is required to import IT-ToolBox or use the naming validators. Get-ReportChain requires an available Get-ADUser command and access to an AD endpoint when invoked; it uses the command's ambient authentication. Tests mock AD queries and verify filter construction; no live domain query is tested. + +## Script context + +Get-ScriptDirectory and Get-ScriptName resolve the immediately calling script rather +than the module implementation file. Calls inside a function defined in a script +use that defining script; a nested or dot-sourced script uses its own path. Calls +made interactively without an explicit path return no output. They do not read the +historical, externally supplied hostinvocation variable. + +```powershell +# Inside automation.ps1: +$scriptDirectory = Get-ScriptDirectory +$scriptName = Get-ScriptName +# Explicit paths also work interactively, including paths that do not yet exist: +Get-ScriptDirectory -ScriptPath './scripts/automation.ps1' +'./scripts/automation.ps1', './scripts/other.ps1' | Get-ScriptName +``` + +ScriptPath accepts literal absolute/relative filesystem filenames and pipeline +input. Relative paths use the current PowerShell location, not the caller's directory. +Wildcard characters are treated literally. Non-filesystem provider paths and paths +without a filename throw; no existence, extension or file-type check is performed. +Unlike the legacy implementations, these helpers do not depend on script-scoped +MyInvocation or silently return a module filename. diff --git a/Tests/Module.Tests.ps1 b/Tests/Module.Tests.ps1 index 2f3c022..a5a805e 100644 --- a/Tests/Module.Tests.ps1 +++ b/Tests/Module.Tests.ps1 @@ -10,7 +10,7 @@ Describe 'IT-ToolBox module boundary' { } It 'exports exactly the supported commands' { - $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-RegistryValue', 'Test-IsValidDn', 'Test-IsValidUpn', 'Get-ReportChain', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object + $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Get-ScriptDirectory', 'Get-ScriptName', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-RegistryValue', 'Test-IsValidDn', 'Test-IsValidUpn', 'Get-ReportChain', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object $actual = @($module.ExportedFunctions.Keys | Sort-Object) ($actual -join ',') | Should -Be ($expected -join ',') $module.ExportedVariables.Count | Should -Be 0 diff --git a/Tests/ScriptContext.Tests.ps1 b/Tests/ScriptContext.Tests.ps1 new file mode 100644 index 0000000..59513ef --- /dev/null +++ b/Tests/ScriptContext.Tests.ps1 @@ -0,0 +1,120 @@ +BeforeAll { + $manifestPath = (Resolve-Path (Join-Path $PSScriptRoot '../IT-ToolBox.psd1')).Path + Import-Module $manifestPath -Force -ErrorAction Stop + $fixtureDirectory = Join-Path $TestDrive 'scripts with spaces' + $null = New-Item -ItemType Directory -Path $fixtureDirectory + $callerPath = Join-Path $fixtureDirectory 'caller.ps1' + $nestedPath = Join-Path $fixtureDirectory 'nested.ps1' + $functionPath = Join-Path $fixtureDirectory 'definition.ps1' + Set-Content -LiteralPath $callerPath -Value @' +[pscustomobject]@{ Directory = Get-ScriptDirectory; Name = Get-ScriptName } +'@ + Set-Content -LiteralPath $nestedPath -Value @' +[pscustomobject]@{ Directory = Get-ScriptDirectory; Name = Get-ScriptName } +'@ + Set-Content -LiteralPath $functionPath -Value @' +function Get-DefinedScriptContext { + [pscustomobject]@{ Directory = Get-ScriptDirectory; Name = Get-ScriptName } +} +'@ +} + +Describe 'Script-context helpers' { + It 'resolves the script calling the imported module' { + $result = & $callerPath + $result.Directory | Should -Be $fixtureDirectory + $result.Name | Should -Be 'caller.ps1' + } + + It 'resolves a dot-sourced script' { + $result = . $callerPath + $result.Directory | Should -Be $fixtureDirectory + $result.Name | Should -Be 'caller.ps1' + } + + It 'resolves the innermost nested script' { + $outerPath = Join-Path $TestDrive 'outer.ps1' + Set-Content -LiteralPath $outerPath -Value '& (Join-Path $PSScriptRoot ''scripts with spaces/nested.ps1'')' + $result = & $outerPath + $result.Directory | Should -Be $fixtureDirectory + $result.Name | Should -Be 'nested.ps1' + } + + It 'resolves the script defining a calling function' { + . $functionPath + $result = Get-DefinedScriptContext + $result.Directory | Should -Be $fixtureDirectory + $result.Name | Should -Be 'definition.ps1' + } + + It 'lets an explicit path override caller context' { + $explicitPath = Join-Path $TestDrive 'not-created.ps1' + Get-ScriptDirectory -ScriptPath $explicitPath | Should -Be $TestDrive + Get-ScriptName -ScriptPath $explicitPath | Should -Be 'not-created.ps1' + Test-Path -LiteralPath $explicitPath | Should -BeFalse + } + + It 'resolves relative paths against the current location' { + Push-Location $TestDrive + try { + Get-ScriptDirectory -ScriptPath './absent/example.ps1' | Should -Be (Join-Path $TestDrive 'absent') + Get-ScriptName -ScriptPath './absent/example.ps1' | Should -Be 'example.ps1' + } + finally { Pop-Location } + } + + It 'treats wildcard characters literally' { + $path = Join-Path $TestDrive 'absent[12].ps1' + Get-ScriptName -ScriptPath $path | Should -Be 'absent[12].ps1' + Get-ScriptDirectory -ScriptPath $path | Should -Be $TestDrive + } + + It 'returns one string per explicit pipeline path' { + $paths = @((Join-Path $TestDrive 'one.ps1'), (Join-Path $fixtureDirectory 'two.ps1')) + $names = @($paths | Get-ScriptName) + $directories = @($paths | Get-ScriptDirectory) + $names.Count | Should -Be 2 + ($names -join ',') | Should -Be 'one.ps1,two.ps1' + $directories.Count | Should -Be 2 + $directories[0] | Should -Be $TestDrive + $directories[1] | Should -Be $fixtureDirectory + } + + It 'rejects explicit empty or null paths for ' -ForEach @( + @{ Command = 'Get-ScriptDirectory' }; @{ Command = 'Get-ScriptName' } + ) { + { & $Command -ScriptPath '' } | Should -Throw + { & $Command -ScriptPath $null } | Should -Throw + } + + It 'rejects a non-filesystem provider for ' -ForEach @( + @{ Command = 'Get-ScriptDirectory' }; @{ Command = 'Get-ScriptName' } + ) { + { & $Command -ScriptPath 'Env:PATH' } | Should -Throw '*filesystem*' + } + + It 'rejects a trailing directory separator for ' -ForEach @( + @{ Command = 'Get-ScriptDirectory' }; @{ Command = 'Get-ScriptName' } + ) { + { & $Command -ScriptPath ($TestDrive + [IO.Path]::DirectorySeparatorChar) } | Should -Throw '*filename*' + } + + It 'returns no interactive output and ignores hostinvocation in an isolated process' { + $escapedManifest = $manifestPath.Replace("'", "''") + $code = @" +Import-Module '$escapedManifest' -ErrorAction Stop +`$global:hostinvocation = @{ MyCommand = @{ Path = '/incorrect/host.ps1'; Name = 'host.ps1' } } +[pscustomobject]@{ + DirectoryCount = @(Get-ScriptDirectory).Count + NameCount = @(Get-ScriptName).Count +} | ConvertTo-Json -Compress +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code)) + $executable = Join-Path $PSHOME $(if ($IsWindows) { 'pwsh.exe' } else { 'pwsh' }) + $output = & $executable -NoProfile -NonInteractive -EncodedCommand $encoded + $LASTEXITCODE | Should -Be 0 + $result = $output | ConvertFrom-Json + $result.DirectoryCount | Should -Be 0 + $result.NameCount | Should -Be 0 + } +}