diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f39cae7..b6cbda0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,12 @@ jobs: - name: Install Pester shell: pwsh run: | - Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -Scope CurrentUser -SkipPublisherCheck + Install-Module -Name Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force -Scope CurrentUser -SkipPublisherCheck - name: Run Pester tests shell: pwsh run: | + Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force $config = New-PesterConfiguration $config.Run.Path = './tests' $config.Run.Exit = $true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45aa05e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow +[Semantic Versioning](https://semver.org/). + +## [1.2.0] - 2026-09-04 + +Gallery-native release: everything that used to require a git clone now works +on a PowerShell Gallery install. + +### Added +- `Initialize-Toolkit`: first-run setup that creates the data folder, copies + `config.example.json` to `config.json`, creates the `creds` folder and opens + the config in your editor. Works for git-clone and Gallery installs. +- `New-SSHCredential`: prompts for an SSH username/password and stores it + DPAPI-encrypted in the creds folder. Replaces the manual `Export-Clixml` steps. +- Tab completion for SSH server aliases on `cssh` / `tunnel` / `Connect-SSH` / + `Connect-SSHTunnel`, and for database names on the tunnel port argument. +- `PSDT_HOME` environment variable to override where config and creds live. +- `PSDT_SKIP_UPDATE_CHECK` environment variable to disable the startup update check. +- `helpme` shows the install type and the resolved `config.json` path. +- This changelog. + +### Changed +- Config and credentials now resolve through a single data folder: the repo root + for git clones (unchanged), `%LOCALAPPDATA%\PowerShellDevToolkit` for Gallery + installs, or `PSDT_HOME` when set. +- `config.example.json` moved from the repo root into the module folder so it + ships in the Gallery package. +- `Update-Toolkit` and the startup update check detect Gallery installs and + compare against the Gallery, updating with `Update-Module` / `Update-PSResource`. +- A missing `config.json` no longer prompts from inside other commands; commands + print where they looked and point to `Initialize-Toolkit`. +- `e` / `Edit-File` no longer prints a config warning when `config.json` is absent. + +### Fixed +- Gallery installs could not locate `config.json`, the `creds` folder or + `config.example.json`, and `Update-Toolkit` failed with "not a git repository". + +### Internal +- Shared `Resolve-SSHTarget` and `Test-WslAvailable` helpers replace about 100 + duplicated lines in `Connect-SSH` and `Connect-SSHTunnel`. +- New `Get-ToolkitPaths` helper is the single source of truth for on-disk paths. +- 50+ new Pester tests; Gallery behavior is tested with mocks, never the network. + +## [1.1.0] - 2026-04-09 + +### Added +- Proper PowerShell module (`PowerShellDevToolkit`) with manifest, Public/Private + layout and exported aliases; published to the PowerShell Gallery. +- All commands from the documentation implemented as module functions. +- `Update-Toolkit` self-update and daily startup update check. +- Pester 5 test suite and GitHub Actions CI on Windows. +- Logo assets in README, setup and `helpme`. + +### Fixed +- Multiple bugs found in code review and by the new tests (parameter alias + conflict in `Copy-ToClipboard`, git detection from subdirectories, Vue/Nuxt + detection, `Write-Warning` shadowing in setup, `Set-ProjectEnv` scope bug). + +## [1.0.0] - 2025-12-11 + +### Added +- Initial release as a collection of standalone scripts: SSH connect and tunnel + with credential and key-file support, dev server launcher, port tools, project + detection, AI rules generation, log tailing and quick utilities. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a65664d..5618dc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme - **Never hardcode user-specific information** - **Use `config.json` for user settings** - **Provide sensible defaults** -- **Document all configuration options** in `config.example.json` +- **Document all configuration options** in `PowerShellDevToolkit\config.example.json` ### Security @@ -122,7 +122,7 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme - Update `README.md` if you add new features - Add examples to help text - Document configuration options -- Update `helpme.ps1` for new commands +- Update `Show-Help` (`Public\Show-Help.ps1`) and `docs/COMMANDS.md` for new commands, and add a `CHANGELOG.md` entry ## Testing @@ -162,16 +162,19 @@ Also test manually: powershell-dev-toolkit/ ├── PowerShellDevToolkit/ # The PS module │ ├── PowerShellDevToolkit.psd1 # Module manifest (version, exports) -│ ├── PowerShellDevToolkit.psm1 # Root module (auto-loader, aliases) +│ ├── PowerShellDevToolkit.psm1 # Root module (auto-loader, aliases, completers) +│ ├── config.example.json # Configuration template (ships in the Gallery package) │ ├── Public/ # Exported functions (one per file) │ │ ├── Connect-SSH.ps1 -│ │ ├── Get-GitQuick.ps1 +│ │ ├── Initialize-Toolkit.ps1 │ │ └── ... │ └── Private/ # Internal helpers (not exported) -│ └── Get-ScriptConfig.ps1 +│ ├── Get-ToolkitPaths.ps1 # Install type + data folder resolution +│ ├── Get-ScriptConfig.ps1 +│ ├── Resolve-SSHTarget.ps1 +│ └── ... ├── tests/ # Pester tests ├── docs/ # Documentation -├── config.example.json # Configuration template ├── Setup-Environment.ps1 # Bootstrap / installer ├── README.md ├── LICENSE diff --git a/Invoke-Tests.ps1 b/Invoke-Tests.ps1 index 492dc8e..029faa7 100644 --- a/Invoke-Tests.ps1 +++ b/Invoke-Tests.ps1 @@ -11,12 +11,12 @@ $ErrorActionPreference = 'Stop' -if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge '5.0' })) { - Write-Host "Pester 5+ not found. Installing from PSGallery..." -ForegroundColor Yellow - Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -Scope CurrentUser -SkipPublisherCheck +if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge '5.0' -and $_.Version -lt '6.0' })) { + Write-Host "Pester 5.x not found. Installing from PSGallery..." -ForegroundColor Yellow + Install-Module -Name Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force -Scope CurrentUser -SkipPublisherCheck } -Import-Module Pester -MinimumVersion 5.0.0 +Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force $config = New-PesterConfiguration $config.Run.Path = Join-Path $PSScriptRoot 'tests' diff --git a/PowerShellDevToolkit/PowerShellDevToolkit.psd1 b/PowerShellDevToolkit/PowerShellDevToolkit.psd1 index 65b0085..0675896 100644 --- a/PowerShellDevToolkit/PowerShellDevToolkit.psd1 +++ b/PowerShellDevToolkit/PowerShellDevToolkit.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PowerShellDevToolkit.psm1' - ModuleVersion = '1.1.0' + ModuleVersion = '1.2.0' GUID = '882e07c2-69ad-46e6-aea6-07adb025f6b3' Author = 'PowerShell Dev Toolkit Contributors' CompanyName = 'Community' @@ -50,6 +50,8 @@ # Toolkit management 'Update-Toolkit' 'Test-ToolkitUpdate' + 'Initialize-Toolkit' + 'New-SSHCredential' ) CmdletsToExport = @() @@ -99,6 +101,18 @@ Tags = @('Windows', 'Developer', 'Productivity', 'SSH', 'DevTools') LicenseUri = 'https://github.com/joshuaevan/powershell-dev-toolkit/blob/main/LICENSE' ProjectUri = 'https://github.com/joshuaevan/powershell-dev-toolkit' + ReleaseNotes = @' +1.2.0 - Gallery-native +- New: Initialize-Toolkit creates config.json and the creds folder for any install type. +- New: New-SSHCredential stores SSH username/password (DPAPI-encrypted) in the creds folder. +- New: Tab completion for SSH server aliases (cssh, tunnel) and database port names. +- New: PSDT_HOME overrides the data folder; PSDT_SKIP_UPDATE_CHECK disables the startup check. +- Changed: Gallery installs keep config and creds in %LOCALAPPDATA%\PowerShellDevToolkit; git clones keep using the repo root. +- Changed: Update-Toolkit and the startup update check work on Gallery installs. +- Changed: config.example.json now ships inside the module folder. +- Fixed: Gallery installs could not find config.json, creds, or config.example.json. +See CHANGELOG.md for details. +'@ } } } diff --git a/PowerShellDevToolkit/PowerShellDevToolkit.psm1 b/PowerShellDevToolkit/PowerShellDevToolkit.psm1 index 77c0414..f940e50 100644 --- a/PowerShellDevToolkit/PowerShellDevToolkit.psm1 +++ b/PowerShellDevToolkit/PowerShellDevToolkit.psm1 @@ -1,5 +1,7 @@ # PowerShellDevToolkit Root Module -# Repo root is one level above the module folder +$script:ModuleRoot = $PSScriptRoot +# Repo root is one level above the module folder (only meaningful for git installs; +# used for repo-only assets such as the ASCII logo). User data paths come from Get-ToolkitPaths. $script:ToolkitRoot = Split-Path $PSScriptRoot # Dot-source private functions first, then public @@ -11,6 +13,9 @@ foreach ($file in @($Private + $Public)) { catch { Write-Error "Failed to import $($file.FullName): $_" } } +# Tab completion for SSH server aliases and database port names +Register-ToolkitArgumentCompleters + # Aliases — existing commands New-Alias -Name cssh -Value Connect-SSH -Force -Scope Global New-Alias -Name tunnel -Value Connect-SSHTunnel -Force -Scope Global @@ -60,4 +65,6 @@ function global:la { Get-DirectoryListing -Force @args } function global:o. { Open-Item . } # Startup update check (runs once per configured interval, silent on error) -if ([Environment]::UserInteractive) { try { Test-ToolkitUpdate } catch { } } +if ([Environment]::UserInteractive -and [string]::IsNullOrEmpty($env:PSDT_SKIP_UPDATE_CHECK)) { + try { Test-ToolkitUpdate } catch { } +} diff --git a/PowerShellDevToolkit/Private/Get-ScriptConfig.ps1 b/PowerShellDevToolkit/Private/Get-ScriptConfig.ps1 index 0638fea..49e8e19 100644 --- a/PowerShellDevToolkit/Private/Get-ScriptConfig.ps1 +++ b/PowerShellDevToolkit/Private/Get-ScriptConfig.ps1 @@ -4,53 +4,42 @@ function Get-ScriptConfig { Load configuration for PowerShell Dev Toolkit. .DESCRIPTION - Loads configuration from config.json relative to the toolkit root. - If config.json doesn't exist, prompts to create from example. + Loads config.json from the toolkit data folder (see Get-ToolkitPaths). + Returns $null when the file is missing or cannot be parsed. + + .PARAMETER Quiet + Suppress all host output. Use from tab completers and from commands + that only need the config if it happens to exist. .EXAMPLE $config = Get-ScriptConfig $config.ssh.servers #> [CmdletBinding()] - param() + param( + [switch]$Quiet + ) - $configPath = Join-Path $script:ToolkitRoot "config.json" - $examplePath = Join-Path $script:ToolkitRoot "config.example.json" + $configPath = (Get-ToolkitPaths).ConfigPath if (-not (Test-Path $configPath)) { - Write-Host "" - Write-Host "Configuration file not found!" -ForegroundColor Yellow - Write-Host "" - Write-Host "To set up your configuration:" -ForegroundColor Cyan - Write-Host " 1. Copy config.example.json to config.json" -ForegroundColor White - Write-Host " 2. Edit config.json with your settings" -ForegroundColor White - Write-Host "" - - if (Test-Path $examplePath) { - Write-Host "Would you like to create config.json from the example now? (Y/N): " -NoNewline -ForegroundColor Yellow - $response = Read-Host - - if ($response -eq 'Y' -or $response -eq 'y') { - Copy-Item $examplePath $configPath - Write-Host "" - Write-Host "Created config.json - please edit it with your settings." -ForegroundColor Green - Write-Host "Location: $configPath" -ForegroundColor Gray - Write-Host "" - - if (Get-Command notepad -ErrorAction SilentlyContinue) { - Start-Process notepad $configPath - } - } + if (-not $Quiet) { + Write-Host "" + Write-Host "Configuration file not found: $configPath" -ForegroundColor Yellow + Write-Host "Run " -NoNewline -ForegroundColor Cyan + Write-Host "Initialize-Toolkit" -NoNewline -ForegroundColor Yellow + Write-Host " to create it." -ForegroundColor Cyan + Write-Host "" } - return $null } try { - $config = Get-Content $configPath -Raw | ConvertFrom-Json - return $config + return (Get-Content $configPath -Raw | ConvertFrom-Json) } catch { - Write-Host "Error loading config.json: $($_.Exception.Message)" -ForegroundColor Red + if (-not $Quiet) { + Write-Host "Error loading config.json ($configPath): $($_.Exception.Message)" -ForegroundColor Red + } return $null } } diff --git a/PowerShellDevToolkit/Private/Get-ToolkitGalleryStatus.ps1 b/PowerShellDevToolkit/Private/Get-ToolkitGalleryStatus.ps1 new file mode 100644 index 0000000..a00ae43 --- /dev/null +++ b/PowerShellDevToolkit/Private/Get-ToolkitGalleryStatus.ps1 @@ -0,0 +1,38 @@ +function Get-ToolkitGalleryStatus { + <# + .SYNOPSIS + Compare the installed module version with the PowerShell Gallery. + + .PARAMETER Paths + The object returned by Get-ToolkitPaths. + + .OUTPUTS + PSCustomObject with Installed, Latest, UpdateAvailable and ReleaseNotes, + or $null when the Gallery cannot be reached. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Paths + ) + + $manifest = Import-PowerShellDataFile (Join-Path $Paths.ModuleRoot 'PowerShellDevToolkit.psd1') + $installed = [version]$manifest.ModuleVersion + + try { + $latest = Find-Module -Name PowerShellDevToolkit -Repository PSGallery -ErrorAction Stop + } + catch { + return $null + } + if (-not $latest) { return $null } + + $latestVersion = [version]$latest.Version + + return [pscustomobject]@{ + Installed = $installed + Latest = $latestVersion + UpdateAvailable = ($latestVersion -gt $installed) + ReleaseNotes = $latest.ReleaseNotes + } +} diff --git a/PowerShellDevToolkit/Private/Get-ToolkitPaths.ps1 b/PowerShellDevToolkit/Private/Get-ToolkitPaths.ps1 new file mode 100644 index 0000000..31dda12 --- /dev/null +++ b/PowerShellDevToolkit/Private/Get-ToolkitPaths.ps1 @@ -0,0 +1,47 @@ +function Get-ToolkitPaths { + <# + .SYNOPSIS + Resolve every on-disk location the toolkit uses. + + .DESCRIPTION + Returns an object describing where the module lives, whether it was + installed from a git checkout or the PowerShell Gallery, and where user + data (config.json, the creds folder, the update-check stamp) is stored. + + Data folder resolution order: + 1. $env:PSDT_HOME when set + 2. The repo root when the module sits inside a git checkout + 3. $env:LOCALAPPDATA\PowerShellDevToolkit + + Evaluated on every call so PSDT_HOME can change at runtime. + + .EXAMPLE + $paths = Get-ToolkitPaths + $paths.ConfigPath + #> + [CmdletBinding()] + param() + + $moduleRoot = $script:ModuleRoot + $parentDir = Split-Path $moduleRoot -Parent + $isGit = Test-Path (Join-Path $parentDir '.git') + + $dataRoot = if (-not [string]::IsNullOrWhiteSpace($env:PSDT_HOME)) { + $env:PSDT_HOME + } elseif ($isGit) { + $parentDir + } else { + Join-Path $env:LOCALAPPDATA 'PowerShellDevToolkit' + } + + [pscustomobject]@{ + InstallType = if ($isGit) { 'Git' } else { 'Gallery' } + ModuleRoot = $moduleRoot + RepoRoot = if ($isGit) { $parentDir } else { $null } + DataRoot = $dataRoot + ConfigPath = Join-Path $dataRoot 'config.json' + ExampleConfigPath = Join-Path $moduleRoot 'config.example.json' + CredsPath = Join-Path $dataRoot 'creds' + UpdateStampPath = Join-Path $dataRoot '.last-update-check' + } +} diff --git a/PowerShellDevToolkit/Private/Register-ToolkitArgumentCompleters.ps1 b/PowerShellDevToolkit/Private/Register-ToolkitArgumentCompleters.ps1 new file mode 100644 index 0000000..71f38db --- /dev/null +++ b/PowerShellDevToolkit/Private/Register-ToolkitArgumentCompleters.ps1 @@ -0,0 +1,47 @@ +function Register-ToolkitArgumentCompleters { + <# + .SYNOPSIS + Register tab completion for SSH server aliases and database port names. + + .DESCRIPTION + Called once when the module loads. Completers read config.json quietly + on every Tab press, so edits to config.json are picked up without + re-importing the module. + #> + [CmdletBinding()] + param() + + $serverCompleter = { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + $config = Get-ScriptConfig -Quiet + if (-not ($config -and $config.ssh -and $config.ssh.servers)) { return } + + foreach ($prop in $config.ssh.servers.PSObject.Properties) { + if ($prop.Name -like "$wordToComplete*") { + $tooltip = [string]$prop.Value.hostname + if ($prop.Value.description) { $tooltip = "$tooltip - $($prop.Value.description)" } + [System.Management.Automation.CompletionResult]::new($prop.Name, $prop.Name, 'ParameterValue', $tooltip) + } + } + } + + $dbPortCompleter = { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + $config = Get-ScriptConfig -Quiet + if (-not ($config -and $config.ssh -and $config.ssh.databasePorts)) { return } + + foreach ($prop in $config.ssh.databasePorts.PSObject.Properties) { + if ($prop.Name -like "$wordToComplete*") { + [System.Management.Automation.CompletionResult]::new($prop.Name, $prop.Name, 'ParameterValue', "port $($prop.Value)") + } + } + } + + $sshCommands = @('Connect-SSH', 'Connect-SSHTunnel', 'cssh', 'tunnel', 'tssh') + $tunnelCommands = @('Connect-SSHTunnel', 'tunnel', 'tssh') + + Register-ArgumentCompleter -CommandName $sshCommands -ParameterName Target -ScriptBlock $serverCompleter + Register-ArgumentCompleter -CommandName $tunnelCommands -ParameterName RemotePort -ScriptBlock $dbPortCompleter +} diff --git a/PowerShellDevToolkit/Private/Resolve-SSHTarget.ps1 b/PowerShellDevToolkit/Private/Resolve-SSHTarget.ps1 new file mode 100644 index 0000000..4f0ff25 --- /dev/null +++ b/PowerShellDevToolkit/Private/Resolve-SSHTarget.ps1 @@ -0,0 +1,125 @@ +function Resolve-SSHTarget { + <# + .SYNOPSIS + Resolve a server alias or hostname into SSH connection details. + + .DESCRIPTION + Looks the target up in config.ssh.servers, resolves the key file (if + any) under the creds folder, and loads the credential file for the + username and password. Prints an error and returns $null when a + required file is missing. + + .PARAMETER Target + Server alias from config.json, or a raw hostname. + + .PARAMETER Config + The object returned by Get-ScriptConfig. + + .OUTPUTS + PSCustomObject with Server, UserName, Password, Credential, KeyFilePath. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [Parameter(Mandatory = $true)] + $Config + ) + + $servers = @{} + $serverKeyFiles = @{} + $serverUsers = @{} + if ($Config.ssh -and $Config.ssh.servers) { + foreach ($key in $Config.ssh.servers.PSObject.Properties.Name) { + $servers[$key] = $Config.ssh.servers.$key.hostname + if ($Config.ssh.servers.$key.keyFile) { + $serverKeyFiles[$key] = $Config.ssh.servers.$key.keyFile + } + if ($Config.ssh.servers.$key.user) { + $serverUsers[$key] = $Config.ssh.servers.$key.user + } + } + } + + $keyFile = $null + $configUser = $null + if ($servers.ContainsKey($Target)) { + $server = $servers[$Target] + if ($serverKeyFiles.ContainsKey($Target)) { + $keyFile = $serverKeyFiles[$Target] + } + if ($serverUsers.ContainsKey($Target)) { + $configUser = $serverUsers[$Target] + } + } + else { + $server = $Target + } + + $credsDir = (Get-ToolkitPaths).CredsPath + + $keyFilePath = $null + if ($keyFile) { + if ([System.IO.Path]::IsPathRooted($keyFile)) { + $keyFilePath = $keyFile + } + else { + $keyFilePath = Join-Path $credsDir $keyFile + } + if (-not (Test-Path $keyFilePath)) { + Write-Host "Key file not found: $keyFilePath" -ForegroundColor Red + return $null + } + } + + $username = $null + $password = $null + $cred = $null + + $credFile = $null + if ($Config.ssh) { $credFile = $Config.ssh.credentialFile } + + if (-not $keyFile) { + if (-not $credFile) { + $credFile = "ssh-credentials.xml" + } + $credPath = Join-Path $credsDir $credFile + + if (-not (Test-Path $credPath)) { + Write-Host "Credential file not found: $credPath" -ForegroundColor Red + return $null + } + + $cred = Import-Clixml $credPath + $username = $cred.UserName + $password = $cred.GetNetworkCredential().Password + } + else { + if ($configUser) { + $username = $configUser + } + else { + if ($credFile) { + $credPath = Join-Path $credsDir $credFile + if (Test-Path $credPath) { + $cred = Import-Clixml $credPath + $username = $cred.UserName + } + } + } + + if (-not $username) { + Write-Host "Username required. Add 'user' to server config in config.json" -ForegroundColor Red + return $null + } + } + + return [pscustomobject]@{ + Server = $server + UserName = $username + Password = $password + Credential = $cred + KeyFilePath = $keyFilePath + } +} diff --git a/PowerShellDevToolkit/Private/Test-WslAvailable.ps1 b/PowerShellDevToolkit/Private/Test-WslAvailable.ps1 new file mode 100644 index 0000000..c26f021 --- /dev/null +++ b/PowerShellDevToolkit/Private/Test-WslAvailable.ps1 @@ -0,0 +1,12 @@ +function Test-WslAvailable { + <# + .SYNOPSIS + Return $true when WSL is installed and a distribution responds. + #> + [CmdletBinding()] + param() + + if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { return $false } + $wslCheck = wsl echo "ok" 2>&1 + return [bool]($wslCheck -eq "ok") +} diff --git a/PowerShellDevToolkit/Public/Connect-SSH.ps1 b/PowerShellDevToolkit/Public/Connect-SSH.ps1 index 5d013d9..a27c808 100644 --- a/PowerShellDevToolkit/Public/Connect-SSH.ps1 +++ b/PowerShellDevToolkit/Public/Connect-SSH.ps1 @@ -23,44 +23,7 @@ function Connect-SSH { return } - $servers = @{} - $serverKeyFiles = @{} - $serverUsers = @{} - if ($config.ssh.servers) { - foreach ($key in $config.ssh.servers.PSObject.Properties.Name) { - $servers[$key] = $config.ssh.servers.$key.hostname - if ($config.ssh.servers.$key.keyFile) { - $serverKeyFiles[$key] = $config.ssh.servers.$key.keyFile - } - if ($config.ssh.servers.$key.user) { - $serverUsers[$key] = $config.ssh.servers.$key.user - } - } - } - - $keyFile = $null - $configUser = $null - if ($servers.ContainsKey($Target)) { - $Server = $servers[$Target] - if ($serverKeyFiles.ContainsKey($Target)) { - $keyFile = $serverKeyFiles[$Target] - } - if ($serverUsers.ContainsKey($Target)) { - $configUser = $serverUsers[$Target] - } - } - else { - $Server = $Target - } - - $useWSL = $false - $wsl = Get-Command wsl -ErrorAction SilentlyContinue - if ($wsl) { - $wslCheck = wsl echo "ok" 2>&1 - if ($wslCheck -eq "ok") { - $useWSL = $true - } - } + $useWSL = Test-WslAvailable if (-not $useWSL) { try { Import-Module Posh-SSH -ErrorAction Stop @@ -73,62 +36,15 @@ function Connect-SSH { } } - $credsDir = Join-Path $script:ToolkitRoot "creds" + $ssh = Resolve-SSHTarget -Target $Target -Config $config + if (-not $ssh) { return } - $keyFilePath = $null - if ($keyFile) { - if ([System.IO.Path]::IsPathRooted($keyFile)) { - $keyFilePath = $keyFile - } - else { - $keyFilePath = Join-Path $credsDir $keyFile - } - if (-not (Test-Path $keyFilePath)) { - Write-Host "Key file not found: $keyFilePath" -ForegroundColor Red - return - } - } - - $username = $null - $password = $null - $cred = $null - - if (-not $keyFile) { - $credFile = $config.ssh.credentialFile - if (-not $credFile) { - $credFile = "ssh-credentials.xml" - } - $credPath = Join-Path $credsDir $credFile - - if (-not (Test-Path $credPath)) { - Write-Host "Credential file not found: $credPath" -ForegroundColor Red - return - } - - $cred = Import-Clixml $credPath - $username = $cred.UserName - $password = $cred.GetNetworkCredential().Password - } - else { - if ($configUser) { - $username = $configUser - } - else { - $credFile = $config.ssh.credentialFile - if ($credFile) { - $credPath = Join-Path $credsDir $credFile - if (Test-Path $credPath) { - $cred = Import-Clixml $credPath - $username = $cred.UserName - } - } - } - - if (-not $username) { - Write-Host "Username required. Add 'user' to server config in config.json" -ForegroundColor Red - return - } - } + $Server = $ssh.Server + $username = $ssh.UserName + $password = $ssh.Password + $cred = $ssh.Credential + $keyFilePath = $ssh.KeyFilePath + $keyFile = [bool]$keyFilePath if ($keyFile) { $winSsh = Get-Command ssh.exe -ErrorAction SilentlyContinue diff --git a/PowerShellDevToolkit/Public/Connect-SSHTunnel.ps1 b/PowerShellDevToolkit/Public/Connect-SSHTunnel.ps1 index 51df65f..fe4a5b8 100644 --- a/PowerShellDevToolkit/Public/Connect-SSHTunnel.ps1 +++ b/PowerShellDevToolkit/Public/Connect-SSHTunnel.ps1 @@ -41,21 +41,6 @@ function Connect-SSHTunnel { return } - $servers = @{} - $serverKeyFiles = @{} - $serverUsers = @{} - if ($config.ssh.servers) { - foreach ($key in $config.ssh.servers.PSObject.Properties.Name) { - $servers[$key] = $config.ssh.servers.$key.hostname - if ($config.ssh.servers.$key.keyFile) { - $serverKeyFiles[$key] = $config.ssh.servers.$key.keyFile - } - if ($config.ssh.servers.$key.user) { - $serverUsers[$key] = $config.ssh.servers.$key.user - } - } - } - $dbPorts = @{} if ($config.ssh.databasePorts) { foreach ($key in $config.ssh.databasePorts.PSObject.Properties.Name) { @@ -84,88 +69,19 @@ function Connect-SSHTunnel { $LocalPort = $RemotePort } - $keyFile = $null - $configUser = $null - if ($servers.ContainsKey($Target)) { - $Server = $servers[$Target] - if ($serverKeyFiles.ContainsKey($Target)) { - $keyFile = $serverKeyFiles[$Target] - } - if ($serverUsers.ContainsKey($Target)) { - $configUser = $serverUsers[$Target] - } - } - else { - $Server = $Target - } - - $credsDir = Join-Path $script:ToolkitRoot "creds" - - $keyFilePath = $null - if ($keyFile) { - if ([System.IO.Path]::IsPathRooted($keyFile)) { - $keyFilePath = $keyFile - } - else { - $keyFilePath = Join-Path $credsDir $keyFile - } - if (-not (Test-Path $keyFilePath)) { - Write-Host "Key file not found: $keyFilePath" -ForegroundColor Red - return - } - } - - $username = $null - $password = $null - $cred = $null - - if (-not $keyFile) { - $credFile = $config.ssh.credentialFile - if (-not $credFile) { - $credFile = "ssh-credentials.xml" - } - $credPath = Join-Path $credsDir $credFile + $ssh = Resolve-SSHTarget -Target $Target -Config $config + if (-not $ssh) { return } - if (-not (Test-Path $credPath)) { - Write-Host "Credential file not found: $credPath" -ForegroundColor Red - return - } - - $cred = Import-Clixml $credPath - $username = $cred.UserName - $password = $cred.GetNetworkCredential().Password - } - else { - if ($configUser) { - $username = $configUser - } - else { - $credFile = $config.ssh.credentialFile - if ($credFile) { - $credPath = Join-Path $credsDir $credFile - if (Test-Path $credPath) { - $cred = Import-Clixml $credPath - $username = $cred.UserName - } - } - } - - if (-not $username) { - Write-Host "Username required. Add 'user' to server config in config.json" -ForegroundColor Red - return - } - } + $Server = $ssh.Server + $username = $ssh.UserName + $password = $ssh.Password + $cred = $ssh.Credential + $keyFilePath = $ssh.KeyFilePath + $keyFile = [bool]$keyFilePath Write-Host "Tunnel: localhost:$LocalPort -> ${RemoteHost}:${RemotePort} (via $Server)" -ForegroundColor Cyan - $useWSL = $false - $wsl = Get-Command wsl -ErrorAction SilentlyContinue - if ($wsl) { - $wslCheck = wsl echo "ok" 2>&1 - if ($wslCheck -eq "ok") { - $useWSL = $true - } - } + $useWSL = Test-WslAvailable if ($keyFile) { $winSsh = Get-Command ssh.exe -ErrorAction SilentlyContinue diff --git a/PowerShellDevToolkit/Public/Edit-File.ps1 b/PowerShellDevToolkit/Public/Edit-File.ps1 index eec68ab..3caa8d0 100644 --- a/PowerShellDevToolkit/Public/Edit-File.ps1 +++ b/PowerShellDevToolkit/Public/Edit-File.ps1 @@ -43,7 +43,7 @@ function Edit-File { $nppExe = $null - $config = Get-ScriptConfig -ErrorAction SilentlyContinue + $config = Get-ScriptConfig -Quiet if ($config -and $config.editor -and $config.editor.notepadPlusPlus) { if (Test-Path $config.editor.notepadPlusPlus) { $nppExe = $config.editor.notepadPlusPlus diff --git a/PowerShellDevToolkit/Public/Edit-Hosts.ps1 b/PowerShellDevToolkit/Public/Edit-Hosts.ps1 index 11014e4..8dcfe15 100644 --- a/PowerShellDevToolkit/Public/Edit-Hosts.ps1 +++ b/PowerShellDevToolkit/Public/Edit-Hosts.ps1 @@ -16,7 +16,7 @@ function Edit-Hosts { $hostsPath = "$env:SystemRoot\System32\drivers\etc\hosts" $nppExe = $null - $config = Get-ScriptConfig -ErrorAction SilentlyContinue + $config = Get-ScriptConfig -Quiet if ($config -and $config.editor -and $config.editor.notepadPlusPlus) { if (Test-Path $config.editor.notepadPlusPlus) { $nppExe = $config.editor.notepadPlusPlus diff --git a/PowerShellDevToolkit/Public/Initialize-Toolkit.ps1 b/PowerShellDevToolkit/Public/Initialize-Toolkit.ps1 new file mode 100644 index 0000000..fc1b43f --- /dev/null +++ b/PowerShellDevToolkit/Public/Initialize-Toolkit.ps1 @@ -0,0 +1,79 @@ +function Initialize-Toolkit { + <# + .SYNOPSIS + First-run setup: create the data folder, config.json and creds folder. + + .DESCRIPTION + Works for both git-clone and PowerShell Gallery installs. Creates the + toolkit data folder (see Get-ToolkitPaths), copies config.example.json + to config.json when it does not exist, creates the creds folder, and + opens config.json in your editor. + + .PARAMETER Force + Replace an existing config.json with the example. + + .PARAMETER NoOpen + Do not open config.json in the editor afterwards. + + .EXAMPLE + Initialize-Toolkit + .EXAMPLE + Initialize-Toolkit -NoOpen + .EXAMPLE + Initialize-Toolkit -Force + #> + [CmdletBinding()] + param( + [switch]$Force, + [switch]$NoOpen + ) + + $paths = Get-ToolkitPaths + + foreach ($dir in @($paths.DataRoot, $paths.CredsPath)) { + if (-not (Test-Path $dir)) { + New-Item -Path $dir -ItemType Directory -Force | Out-Null + Write-Host "Created folder: $dir" -ForegroundColor Green + } + } + + if (-not (Test-Path $paths.ExampleConfigPath)) { + Write-Host "Example config not found: $($paths.ExampleConfigPath)" -ForegroundColor Red + return + } + + $configExisted = Test-Path $paths.ConfigPath + if ($configExisted -and -not $Force) { + Write-Host "Config already exists: $($paths.ConfigPath)" -ForegroundColor Yellow + Write-Host "Use -Force to replace it with the example." -ForegroundColor Gray + } else { + Copy-Item $paths.ExampleConfigPath $paths.ConfigPath -Force + $verb = if ($configExisted) { 'Replaced' } else { 'Created' } + Write-Host "$verb config: $($paths.ConfigPath)" -ForegroundColor Green + } + + Write-Host "" + Write-Host "PowerShell Dev Toolkit" -ForegroundColor Cyan + Write-Host " Install type: " -NoNewline -ForegroundColor Gray + Write-Host $paths.InstallType -ForegroundColor White + Write-Host " Data folder: " -NoNewline -ForegroundColor Gray + Write-Host $paths.DataRoot -ForegroundColor White + Write-Host " Config: " -NoNewline -ForegroundColor Gray + Write-Host $paths.ConfigPath -ForegroundColor White + Write-Host " Credentials: " -NoNewline -ForegroundColor Gray + Write-Host $paths.CredsPath -ForegroundColor White + Write-Host "" + Write-Host "Next steps:" -ForegroundColor Cyan + Write-Host " 1. Edit config.json and add your SSH servers" -ForegroundColor White + Write-Host " 2. Run " -NoNewline -ForegroundColor White + Write-Host "New-SSHCredential" -NoNewline -ForegroundColor Yellow + Write-Host " to store your SSH username and password" -ForegroundColor White + Write-Host " 3. Run " -NoNewline -ForegroundColor White + Write-Host "helpme" -NoNewline -ForegroundColor Yellow + Write-Host " to see all commands" -ForegroundColor White + Write-Host "" + + if (-not $NoOpen) { + Edit-File $paths.ConfigPath + } +} diff --git a/PowerShellDevToolkit/Public/New-SSHCredential.ps1 b/PowerShellDevToolkit/Public/New-SSHCredential.ps1 new file mode 100644 index 0000000..23e9bc8 --- /dev/null +++ b/PowerShellDevToolkit/Public/New-SSHCredential.ps1 @@ -0,0 +1,86 @@ +function New-SSHCredential { + <# + .SYNOPSIS + Store SSH credentials for Connect-SSH and Connect-SSHTunnel. + + .DESCRIPTION + Prompts for a username and password and saves them, encrypted with + Windows DPAPI, to the toolkit creds folder (see Get-ToolkitPaths). + The file can only be decrypted by your Windows account on this machine. + + .PARAMETER UserName + Pre-fill the username in the credential prompt. + + .PARAMETER Credential + Supply a PSCredential directly instead of prompting. + + .PARAMETER FileName + Credential file name. Defaults to ssh.credentialFile from config.json, + or ssh-credentials.xml when not configured. + + .PARAMETER Force + Overwrite an existing credential file. + + .EXAMPLE + New-SSHCredential + .EXAMPLE + New-SSHCredential -UserName deploy + .EXAMPLE + New-SSHCredential -UserName prod-user -FileName prod-credentials.xml + #> + [CmdletBinding()] + param( + [Parameter(Position = 0)] + [string]$UserName, + + [System.Management.Automation.PSCredential]$Credential, + + [string]$FileName, + + [switch]$Force + ) + + $paths = Get-ToolkitPaths + + if (-not $FileName) { + $config = Get-ScriptConfig -Quiet + if ($config -and $config.ssh -and $config.ssh.credentialFile) { + $FileName = $config.ssh.credentialFile + } else { + $FileName = 'ssh-credentials.xml' + } + } + + if (-not (Test-Path $paths.CredsPath)) { + New-Item -Path $paths.CredsPath -ItemType Directory -Force | Out-Null + } + + $target = Join-Path $paths.CredsPath $FileName + + if ((Test-Path $target) -and -not $Force) { + Write-Host "Credential file already exists: $target" -ForegroundColor Yellow + Write-Host "Use -Force to overwrite it." -ForegroundColor Gray + return + } + + if (-not $Credential) { + $promptArgs = @{ Message = 'Enter your SSH username and password' } + if ($UserName) { $promptArgs.UserName = $UserName } + $Credential = Get-Credential @promptArgs + if (-not $Credential) { + Write-Host "Cancelled. No credential file written." -ForegroundColor Yellow + return + } + } + + $Credential | Export-Clixml -Path $target + + Write-Host "" + Write-Host "Saved credentials for " -NoNewline -ForegroundColor Green + Write-Host $Credential.UserName -NoNewline -ForegroundColor Yellow + Write-Host " to:" -ForegroundColor Green + Write-Host " $target" -ForegroundColor White + Write-Host "" + Write-Host "The file is encrypted with Windows DPAPI and can only be read by your account on this machine." -ForegroundColor Gray + Write-Host "" +} diff --git a/PowerShellDevToolkit/Public/Show-Help.ps1 b/PowerShellDevToolkit/Public/Show-Help.ps1 index b851c7a..b2f2567 100644 --- a/PowerShellDevToolkit/Public/Show-Help.ps1 +++ b/PowerShellDevToolkit/Public/Show-Help.ps1 @@ -73,7 +73,8 @@ function Show-Help { Write-Option " Ex: tunnel myserver | tunnel myserver 3306 | tunnel myserver postgres 5433" Write-Option " DB shortcuts: postgres, mysql, mssql, mongodb, redis, oracle" Write-Option " Supports key files (.pem) - add keyFile to server config" - Write-Option " Configure servers in config.json (copy from config.example.json)" + Write-Option " Configure servers in config.json (run Initialize-Toolkit to create it)" + Write-Option " Press Tab after 'cssh ' or 'tunnel ' to complete server aliases" Write-Header "AI INTEGRATION COMMANDS" Write-Cmd "ai-rules [-RuleType ]" "Generate AI rules files (Generic/Cursor/Claude)" @@ -129,11 +130,15 @@ function Show-Help { Write-Option " art tinker # Interactive REPL" Write-Header "TOOLKIT MANAGEMENT" - Write-Cmd "Update-Toolkit" "Self-update the toolkit from git" - Write-Option " Update-Toolkit # Pull latest and reload" + Write-Cmd "Initialize-Toolkit [-Force] [-NoOpen]" "First-run setup: create config.json and the creds folder" + Write-Cmd "New-SSHCredential [-UserName ] [-FileName ]" "Store SSH username/password (DPAPI-encrypted)" + Write-Cmd "Update-Toolkit" "Self-update from git or the PowerShell Gallery" + Write-Option " Update-Toolkit # Update and reload" Write-Option " Update-Toolkit -CheckOnly # Check without applying" Write-Option " Update-Toolkit -Force # Skip confirmation prompt" Write-Option " Auto-checks on startup (set toolkit.updateCheckDays in config.json)" + $toolkitPaths = Get-ToolkitPaths + Write-Option " Install: $($toolkitPaths.InstallType) Config: $($toolkitPaths.ConfigPath)" Write-Header "KEYBOARD SHORTCUTS" Write-Cmd "$([char]0x2191) / $([char]0x2193)" "Search history by prefix" diff --git a/PowerShellDevToolkit/Public/Update-Toolkit.ps1 b/PowerShellDevToolkit/Public/Update-Toolkit.ps1 index 0d348d6..8904595 100644 --- a/PowerShellDevToolkit/Public/Update-Toolkit.ps1 +++ b/PowerShellDevToolkit/Public/Update-Toolkit.ps1 @@ -1,12 +1,14 @@ function Update-Toolkit { <# .SYNOPSIS - Self-update the PowerShell Dev Toolkit from its git remote. + Self-update the PowerShell Dev Toolkit. .DESCRIPTION - Pulls the latest changes from the toolkit's git repository, shows a - summary of what changed, and re-imports the module so new commands and - aliases take effect immediately. + For a git-clone install, pulls the latest changes from the toolkit's git + remote. For a PowerShell Gallery install, checks the Gallery for a newer + version and updates with Update-Module (or Update-PSResource). In both + cases the module is re-imported so new commands and aliases take effect + immediately. .PARAMETER CheckOnly Only check whether updates are available without applying them. @@ -27,12 +29,26 @@ function Update-Toolkit { [switch]$Force ) - $toolkitDir = $script:ToolkitRoot + $paths = Get-ToolkitPaths - if (-not (Test-Path (Join-Path $toolkitDir ".git"))) { - Write-Error "Toolkit directory is not a git repository: $toolkitDir" - return + if ($paths.InstallType -eq 'Gallery') { + Update-ToolkitFromGallery -Paths $paths -CheckOnly:$CheckOnly -Force:$Force + } + else { + Update-ToolkitFromGit -Paths $paths -CheckOnly:$CheckOnly -Force:$Force } +} + +function Update-ToolkitFromGit { + <# Git-clone update flow (private). #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] $Paths, + [switch]$CheckOnly, + [switch]$Force + ) + + $toolkitDir = $Paths.RepoRoot $git = Get-Command git -ErrorAction SilentlyContinue if (-not $git) { @@ -56,7 +72,7 @@ function Update-Toolkit { if ($localHead -eq $remoteHead) { Write-Host "Already up to date." -ForegroundColor Green - $manifest = Import-PowerShellDataFile (Join-Path $toolkitDir "PowerShellDevToolkit\PowerShellDevToolkit.psd1") + $manifest = Import-PowerShellDataFile (Join-Path $Paths.ModuleRoot "PowerShellDevToolkit.psd1") Write-Host " Version: $($manifest.ModuleVersion)" -ForegroundColor Gray Write-Host " Branch: $currentBranch" -ForegroundColor Gray return @@ -107,9 +123,9 @@ function Update-Toolkit { Write-Host "" Write-Host "Re-importing module..." -ForegroundColor Cyan - Import-Module (Join-Path $toolkitDir "PowerShellDevToolkit") -Force -Global -DisableNameChecking + Import-Module $Paths.ModuleRoot -Force -Global -DisableNameChecking - $manifest = Import-PowerShellDataFile (Join-Path $toolkitDir "PowerShellDevToolkit\PowerShellDevToolkit.psd1") + $manifest = Import-PowerShellDataFile (Join-Path $Paths.ModuleRoot "PowerShellDevToolkit.psd1") Write-Host "" Write-Host "Updated to version $($manifest.ModuleVersion)" -ForegroundColor Green Write-Host "All commands and aliases are now current." -ForegroundColor Green @@ -120,26 +136,100 @@ function Update-Toolkit { } } +function Update-ToolkitFromGallery { + <# PowerShell Gallery update flow (private). #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] $Paths, + [switch]$CheckOnly, + [switch]$Force + ) + + Write-Host "Checking the PowerShell Gallery for updates..." -ForegroundColor Cyan + $status = Get-ToolkitGalleryStatus -Paths $Paths + if (-not $status) { + Write-Host "Could not reach the PowerShell Gallery. Check your network connection and try again." -ForegroundColor Red + return + } + + if (-not $status.UpdateAvailable) { + Write-Host "Already up to date." -ForegroundColor Green + Write-Host " Version: $($status.Installed)" -ForegroundColor Gray + Write-Host " Source: PowerShell Gallery" -ForegroundColor Gray + Set-ToolkitUpdateTimestamp + return + } + + Write-Host "Update available: $($status.Installed) -> $($status.Latest)" -ForegroundColor Yellow + if ($status.ReleaseNotes) { + Write-Host "" + Write-Host "Release notes:" -ForegroundColor Cyan + ($status.ReleaseNotes -split "`r?`n") | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } + } + Write-Host "" + + if ($CheckOnly) { return } + + if (-not $Force) { + Write-Host "Apply update? (Y/N): " -NoNewline -ForegroundColor Yellow + $response = Read-Host + if ($response -ne 'Y' -and $response -ne 'y') { + Write-Host "Update cancelled." -ForegroundColor Gray + return + } + } + + Write-Host "Updating from the PowerShell Gallery..." -ForegroundColor Cyan + try { + if (Get-InstalledModule -Name PowerShellDevToolkit -ErrorAction SilentlyContinue) { + Update-Module -Name PowerShellDevToolkit -Force -ErrorAction Stop + } + elseif ((Get-Command Update-PSResource -ErrorAction SilentlyContinue) -and + (Get-InstalledPSResource -Name PowerShellDevToolkit -ErrorAction SilentlyContinue)) { + Update-PSResource -Name PowerShellDevToolkit -ErrorAction Stop + } + else { + Write-Host "Could not determine how the module was installed." -ForegroundColor Red + Write-Host "Update manually with: Update-Module PowerShellDevToolkit (or: Update-PSResource PowerShellDevToolkit)" -ForegroundColor Yellow + return + } + } + catch { + Write-Host "Update failed: $($_.Exception.Message)" -ForegroundColor Red + return + } + + Write-Host "" + Write-Host "Re-importing module..." -ForegroundColor Cyan + Import-Module PowerShellDevToolkit -Force -Global -DisableNameChecking + + $newVersion = (Get-Module PowerShellDevToolkit).Version + Write-Host "" + Write-Host "Updated to version $newVersion" -ForegroundColor Green + Write-Host "All commands and aliases are now current." -ForegroundColor Green + + Set-ToolkitUpdateTimestamp +} + function Test-ToolkitUpdate { <# .SYNOPSIS Silently check if toolkit updates are available (used on shell startup). .DESCRIPTION - Compares the local HEAD against the remote. Returns $true if there are - commits to pull. Respects the updateCheckDays setting in config.json - so it only hits the network at the configured frequency. + Compares the local install against its source (git remote or the + PowerShell Gallery). Respects the updateCheckDays setting in config.json + so it only hits the network at the configured frequency. Set + $env:PSDT_SKIP_UPDATE_CHECK to skip the startup check entirely. #> [CmdletBinding()] param() - $toolkitDir = $script:ToolkitRoot + $paths = Get-ToolkitPaths - if (-not (Test-Path (Join-Path $toolkitDir ".git"))) { return } - if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return } + if ($paths.InstallType -eq 'Git' -and -not (Get-Command git -ErrorAction SilentlyContinue)) { return } - $stampFile = Join-Path $toolkitDir ".last-update-check" - $config = Get-ScriptConfig -ErrorAction SilentlyContinue + $config = Get-ScriptConfig -Quiet $intervalDays = 1 if ($config -and $config.toolkit -and $null -ne $config.toolkit.updateCheckDays) { $intervalDays = [int]$config.toolkit.updateCheckDays @@ -147,12 +237,21 @@ function Test-ToolkitUpdate { if ($intervalDays -le 0) { return } - if (Test-Path $stampFile) { - $lastCheck = (Get-Item $stampFile).LastWriteTime + if (Test-Path $paths.UpdateStampPath) { + $lastCheck = (Get-Item $paths.UpdateStampPath).LastWriteTime if (([datetime]::Now - $lastCheck).TotalDays -lt $intervalDays) { return } } - Push-Location $toolkitDir + if ($paths.InstallType -eq 'Gallery') { + $status = Get-ToolkitGalleryStatus -Paths $paths + Set-ToolkitUpdateTimestamp + if ($status -and $status.UpdateAvailable) { + Write-ToolkitUpdateHint "version $($status.Latest) is" + } + return + } + + Push-Location $paths.RepoRoot try { $branch = git rev-parse --abbrev-ref HEAD 2>$null if (-not $branch) { return } @@ -165,20 +264,33 @@ function Test-ToolkitUpdate { if ($local -ne $remote) { $behind = git rev-list --count "HEAD..origin/$branch" 2>$null - Write-Host "" - Write-Host "PowerShell Dev Toolkit: $behind update(s) available. Run " -NoNewline -ForegroundColor Yellow - Write-Host "Update-Toolkit" -NoNewline -ForegroundColor Cyan - Write-Host " to update." -ForegroundColor Yellow + Write-ToolkitUpdateHint "$behind update(s)" } } finally { Pop-Location } } +function Write-ToolkitUpdateHint { + <# Prints the one-line "update available" hint (private). #> + [CmdletBinding()] + param([string]$What) + + Write-Host "" + Write-Host "PowerShell Dev Toolkit: $What available. Run " -NoNewline -ForegroundColor Yellow + Write-Host "Update-Toolkit" -NoNewline -ForegroundColor Cyan + Write-Host " to update." -ForegroundColor Yellow +} + function Set-ToolkitUpdateTimestamp { - <# Touches the .last-update-check stamp file. #> + <# Touches the .last-update-check stamp file (private). #> [CmdletBinding()] param() - $stampFile = Join-Path $script:ToolkitRoot ".last-update-check" + + $stampFile = (Get-ToolkitPaths).UpdateStampPath + $stampDir = Split-Path $stampFile -Parent + if (-not (Test-Path $stampDir)) { + New-Item -Path $stampDir -ItemType Directory -Force | Out-Null + } [IO.File]::WriteAllText($stampFile, (Get-Date -Format 'o')) } diff --git a/PowerShellDevToolkit/Public/Use-NppForGit.ps1 b/PowerShellDevToolkit/Public/Use-NppForGit.ps1 index ce51719..7aa4d26 100644 --- a/PowerShellDevToolkit/Public/Use-NppForGit.ps1 +++ b/PowerShellDevToolkit/Public/Use-NppForGit.ps1 @@ -15,7 +15,7 @@ function Use-NppForGit { param() $nppExe = $null - $config = Get-ScriptConfig -ErrorAction SilentlyContinue + $config = Get-ScriptConfig -Quiet if ($config -and $config.editor -and $config.editor.notepadPlusPlus) { if (Test-Path $config.editor.notepadPlusPlus) { $nppExe = $config.editor.notepadPlusPlus diff --git a/config.example.json b/PowerShellDevToolkit/config.example.json similarity index 100% rename from config.example.json rename to PowerShellDevToolkit/config.example.json diff --git a/README.md b/README.md index 57d94b5..e8ca162 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,13 @@ Install-PSResource PowerShellDevToolkit # Import it (add this line to your $PROFILE to load automatically) Import-Module PowerShellDevToolkit +# First-run setup: creates config.json and the creds folder, then opens the config +Initialize-Toolkit + +# Store your SSH username and password (encrypted with Windows DPAPI) +New-SSHCredential + # Verify installation -Get-Command -Module PowerShellDevToolkit helpme ``` @@ -50,10 +55,10 @@ helpme ```powershell .\Setup-Environment.ps1 ``` -3. **Configure your settings** +3. **Create your configuration** ```powershell - Copy-Item config.example.json config.json - notepad config.json + Initialize-Toolkit # creates config.json, opens it in your editor + New-SSHCredential # stores your SSH username/password ``` 4. **Reload your profile** ```powershell @@ -83,14 +88,37 @@ helpme - **Python** - For Python projects - [Download](https://www.python.org/downloads/) - **Composer** - For PHP dependency management - [Download](https://getcomposer.org/) +## Configuration + +`Initialize-Toolkit` creates `config.json` and a `creds` folder in the toolkit +data folder. Where that is depends on how you installed: + +| Install type | Data folder | +|--------------|-------------| +| Git clone | The repo root (same as previous versions) | +| PowerShell Gallery | `%LOCALAPPDATA%\PowerShellDevToolkit` | +| Either, with `PSDT_HOME` set | `$env:PSDT_HOME` | + +`helpme` shows the resolved path. Set `PSDT_SKIP_UPDATE_CHECK=1` to disable the +startup update check, or set `toolkit.updateCheckDays` to `0` in `config.json`. + ## Key Commands +### Toolkit + +```powershell +Initialize-Toolkit # First-run setup (config.json + creds folder) +New-SSHCredential # Store SSH username/password +Update-Toolkit # Update from git or the PowerShell Gallery +``` + ### SSH & Remote Access ```powershell cssh myserver # SSH to server tunnel myserver postgres # PostgreSQL tunnel (port 5432) tunnel myserver mysql 3307 # MySQL tunnel with custom local port +cssh # Tab-complete server aliases from config.json ``` ### Development diff --git a/Setup-Environment.ps1 b/Setup-Environment.ps1 index efc9e25..7b3f3e3 100644 --- a/Setup-Environment.ps1 +++ b/Setup-Environment.ps1 @@ -526,10 +526,9 @@ Write-Host " for command reference." Write-Host "SSH CREDENTIALS SETUP:" -ForegroundColor Yellow Write-Host " To use SSH commands (cssh, tunnel), you need to store credentials." -ForegroundColor Gray Write-Host "" - Write-Host " Run these commands to set up credentials:" -ForegroundColor Cyan - Write-Host " # First, make sure you have config.json set up (copy from config.example.json)" -ForegroundColor Gray - Write-Host " `$cred = Get-Credential -UserName 'your-ssh-username'" -ForegroundColor Yellow - Write-Host " `$cred | Export-Clixml '$credsDir\ssh-credentials.xml'" -ForegroundColor Yellow + Write-Host " After reloading your profile, run:" -ForegroundColor Cyan + Write-Host " Initialize-Toolkit # creates config.json and the creds folder, opens config" -ForegroundColor Yellow + Write-Host " New-SSHCredential # stores your SSH username and password (DPAPI-encrypted)" -ForegroundColor Yellow Write-Host "" } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index c5bab57..693e7f2 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -13,7 +13,7 @@ | [SSH](#ssh-commands) | `cssh`, `tunnel`, `tssh` | | [AI Integration](#ai-integration-commands) | `ai-rules`, `context` | | [Development](#development-commands) | `port`, `proj`, `serve`, `gs`, `search`, `http`, `services`, `useenv`, `tail`, `clip`, `art` | -| [Toolkit Management](#toolkit-management) | `Update-Toolkit` | +| [Toolkit Management](#toolkit-management) | `Initialize-Toolkit`, `New-SSHCredential`, `Update-Toolkit` | --- @@ -202,6 +202,10 @@ tunnel myserver 5432 5433 # Custom remote and local ports tunnel myserver 3306 -RemoteHost db.internal # Tunnel to internal host ``` +**Tab completion:** press Tab after `cssh ` or `tunnel ` to cycle through the +server aliases in `config.json`. After the server on `tunnel`, Tab cycles the +database shortcut names. + **Database Shortcuts:** | Shortcut | Port | |----------|------| @@ -365,11 +369,41 @@ art cache:clear # Clear application cache ## Toolkit Management +### `Initialize-Toolkit` +First-run setup. Creates the data folder, copies `config.example.json` to +`config.json` (if missing), creates the `creds` folder and opens the config in +your editor. Works for git-clone and PowerShell Gallery installs. + +```powershell +Initialize-Toolkit # Create config + creds folder, open config +Initialize-Toolkit -NoOpen # Do not launch the editor +Initialize-Toolkit -Force # Replace an existing config.json with the example +``` + +**Where files live:** +| Install type | Data folder | +|--------------|-------------| +| Git clone | repo root | +| PowerShell Gallery | `%LOCALAPPDATA%\PowerShellDevToolkit` | +| `PSDT_HOME` set | `$env:PSDT_HOME` | + +### `New-SSHCredential` +Store SSH credentials for `cssh` and `tunnel`. The file is encrypted with Windows +DPAPI and only readable by your account on this machine. + +```powershell +New-SSHCredential # Prompt, save as ssh-credentials.xml +New-SSHCredential -UserName deploy # Pre-fill the username +New-SSHCredential -FileName prod-creds.xml # Custom file name (see ssh.credentialFile) +New-SSHCredential -Force # Overwrite an existing file +``` + ### `Update-Toolkit` -Self-update the toolkit by pulling the latest changes from git. +Self-update the toolkit. Git clones pull from the remote; Gallery installs check +the PowerShell Gallery and update with `Update-Module` / `Update-PSResource`. ```powershell -Update-Toolkit # Pull latest, show changes, reload module +Update-Toolkit # Update, show changes, reload module Update-Toolkit -CheckOnly # Check for updates without applying Update-Toolkit -Force # Skip confirmation prompt ``` @@ -388,7 +422,8 @@ The toolkit checks for available updates once per day on shell startup (configur } ``` -Set to `0` to disable automatic checks entirely. +Set to `0` to disable automatic checks entirely, or set the environment variable +`PSDT_SKIP_UPDATE_CHECK=1`. --- diff --git a/docs/SSH-SETUP.md b/docs/SSH-SETUP.md index b8f796e..41e7682 100644 --- a/docs/SSH-SETUP.md +++ b/docs/SSH-SETUP.md @@ -20,13 +20,14 @@ You need **one** of the following: ### 1. Create Configuration File -Copy the example config and edit it: - ```powershell -Copy-Item config.example.json config.json -notepad config.json +Initialize-Toolkit ``` +This creates `config.json` (from the bundled example) and the `creds` folder in +the toolkit data folder, then opens the config in your editor. Run `helpme` at +any time to see where the config lives. + ### 2. Configure Your Servers Edit `config.json` with your server details: @@ -68,17 +69,13 @@ Edit `config.json` with your server details: ### 3. Store SSH Credentials -Create encrypted credentials (Windows DPAPI - only works on your machine): - ```powershell -# Create creds directory -New-Item -Path ".\creds" -ItemType Directory -Force - -# Store your credentials -$cred = Get-Credential -UserName 'your-ssh-username' -$cred | Export-Clixml '.\creds\ssh-credentials.xml' +New-SSHCredential -UserName 'your-ssh-username' ``` +You are prompted for the password. The file is saved as +`creds\ssh-credentials.xml` in the toolkit data folder. + > **Security Note:** The credential file is encrypted using Windows DPAPI and can only be decrypted by your Windows user account on this machine. ### 4. Test Your Connection @@ -143,7 +140,7 @@ Specify a custom credential file name: } ``` -The file is always stored in the `creds` subdirectory. +The file is always stored in the `creds` folder of the toolkit data folder (repo root for git clones, `%LOCALAPPDATA%\PowerShellDevToolkit` for Gallery installs, or `PSDT_HOME`). --- @@ -210,13 +207,8 @@ For different servers with different credentials: ### 1. Create Multiple Credential Files ```powershell -# Production credentials -$prodCred = Get-Credential -UserName 'prod-user' -$prodCred | Export-Clixml '.\creds\prod-credentials.xml' - -# Development credentials -$devCred = Get-Credential -UserName 'dev-user' -$devCred | Export-Clixml '.\creds\dev-credentials.xml' +New-SSHCredential -UserName 'prod-user' -FileName 'prod-credentials.xml' +New-SSHCredential -UserName 'dev-user' -FileName 'dev-credentials.xml' ``` ### 2. Switch Between Credentials @@ -269,8 +261,9 @@ For key-based authentication (common with AWS, cloud providers): ### 1. Place Key File in Creds Directory ```powershell -# Copy your .pem file to the creds directory -Copy-Item 'C:\Downloads\my-server-key.pem' '.\creds\my-server-key.pem' +# Copy your .pem file into the creds folder (path shown by Initialize-Toolkit / helpme) +Copy-Item 'C:\Downloads\my-server-key.pem' "$env:LOCALAPPDATA\PowerShellDevToolkit\creds\" # Gallery install +Copy-Item 'C:\Downloads\my-server-key.pem' '.\creds\' # git clone (from repo root) ``` ### 2. Configure Server with Key File @@ -289,14 +282,14 @@ Add `keyFile` to your server config: ### 3. Create Credential File for Username -Key file auth still needs a username (stored in credential file): +Key file auth still needs a username. Either add `"user": "ec2-user"` to the +server entry in `config.json`, or store one with: ```powershell -$cred = Get-Credential -UserName 'ec2-user' -$cred | Export-Clixml '.\creds\ssh-credentials.xml' +New-SSHCredential -UserName 'ec2-user' ``` -> **Tip:** Password field can be anything when using key files - only username is used. +> **Tip:** The password can be anything when using key files - only the username is used. ### 4. Connect Using Key File @@ -327,7 +320,7 @@ wsl bash -c "ssh-copy-id username@server.example.com" ```powershell # Copy private key to creds directory -wsl bash -c "cat ~/.ssh/id_ed25519" | Set-Content '.\creds\my-key.pem' +wsl bash -c "cat ~/.ssh/id_ed25519" | Set-Content "$env:LOCALAPPDATA\PowerShellDevToolkit\creds\my-key.pem" # or .\creds\my-key.pem for a git clone ``` Then configure `keyFile` in your server config as shown above. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 81ab157..58134d5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -59,6 +59,13 @@ notepad $PROFILE ``` +### Update-Toolkit Says "Not a Git Repository" + +This happened on PowerShell Gallery installs before 1.2.0. Update once with +`Update-Module PowerShellDevToolkit` (or `Update-PSResource PowerShellDevToolkit`); +from 1.2.0 on, `Update-Toolkit` detects Gallery installs and updates through the +Gallery itself. + --- ## Script Execution Issues @@ -111,14 +118,12 @@ powershell -ExecutionPolicy Bypass -File .\script.ps1 **Solution:** ```powershell -# Create creds directory -New-Item -Path ".\creds" -ItemType Directory -Force - -# Store credentials -$cred = Get-Credential -UserName 'your-ssh-username' -$cred | Export-Clixml '.\creds\ssh-credentials.xml' +New-SSHCredential -UserName 'your-ssh-username' ``` +The message shows the exact path that was checked. Run `helpme` to see the +toolkit data folder if it is not where you expect. + ### "Neither WSL nor Posh-SSH Available" **Problem:** No SSH method available. @@ -157,10 +162,9 @@ $cred | Export-Clixml '.\creds\ssh-credentials.xml' **Solutions:** 1. Verify username is correct -2. Re-create credential file: +2. Re-create the credential file: ```powershell - $cred = Get-Credential -UserName 'correct-username' - $cred | Export-Clixml '.\creds\ssh-credentials.xml' + New-SSHCredential -UserName 'correct-username' -Force ``` 3. Test password manually: ```powershell @@ -258,10 +262,23 @@ Remove-Alias **Solution:** ```powershell -Copy-Item config.example.json config.json -notepad config.json +Initialize-Toolkit ``` +### Where Is My config.json? + +The toolkit keeps `config.json` and `creds\` in a data folder that depends on +the install type: + +| Install type | Data folder | +|--------------|-------------| +| Git clone | repo root | +| PowerShell Gallery | `%LOCALAPPDATA%\PowerShellDevToolkit` | +| `PSDT_HOME` set | `$env:PSDT_HOME` | + +`helpme` prints the resolved path. To move your config, set `PSDT_HOME` in your +profile before `Import-Module PowerShellDevToolkit`. + ### Invalid JSON in Config **Problem:** `ConvertFrom-Json` error when loading config. @@ -280,7 +297,7 @@ notepad config.json 3. Reset to example: ```powershell - Copy-Item config.example.json config.json + Initialize-Toolkit -Force ``` ### Paths With Spaces diff --git a/tests/ArgumentCompleters.Tests.ps1 b/tests/ArgumentCompleters.Tests.ps1 new file mode 100644 index 0000000..3cb6c7a --- /dev/null +++ b/tests/ArgumentCompleters.Tests.ps1 @@ -0,0 +1,84 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + Import-Module $moduleDir -Force -DisableNameChecking + $script:savedPsdtHome = $env:PSDT_HOME + + $script:tkHome = Join-Path $env:TEMP "pester-completers-$(Get-Random)" + New-Item -Path $script:tkHome -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $script:tkHome + Set-Content (Join-Path $script:tkHome 'config.json') @' +{ + "ssh": { + "servers": { + "alpha": { "hostname": "a.example.com", "description": "Alpha box" }, + "beta": { "hostname": "b.example.com" } + }, + "databasePorts": { "postgres": 5432, "mysql": 3306 } + } +} +'@ + + function Get-Completions { + param([string]$Line) + (TabExpansion2 -inputScript $Line -cursorColumn $Line.Length).CompletionMatches + } +} + +AfterAll { + $env:PSDT_HOME = $script:savedPsdtHome + Remove-Item $script:tkHome -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe "SSH argument completers" { + It "Completes server aliases for Connect-SSH -Target" { + (Get-Completions 'Connect-SSH al').CompletionText | Should -Contain 'alpha' + } + + It "Lists every server alias when nothing has been typed" { + $names = (Get-Completions 'Connect-SSH ').CompletionText + $names | Should -Contain 'alpha' + $names | Should -Contain 'beta' + } + + It "Puts the hostname and description in the tooltip" { + $match = Get-Completions 'Connect-SSH alpha' | Where-Object CompletionText -eq 'alpha' + $match.ToolTip | Should -Match 'a\.example\.com' + $match.ToolTip | Should -Match 'Alpha box' + } + + It "Completes server aliases through the cssh alias" { + (Get-Completions 'cssh al').CompletionText | Should -Contain 'alpha' + } + + It "Completes server aliases for Connect-SSHTunnel" { + (Get-Completions 'Connect-SSHTunnel be').CompletionText | Should -Contain 'beta' + } + + It "Completes server aliases through the tunnel alias" { + (Get-Completions 'tunnel be').CompletionText | Should -Contain 'beta' + } + + It "Completes database names for the tunnel port argument" { + $matches = Get-Completions 'Connect-SSHTunnel alpha po' + $matches.CompletionText | Should -Contain 'postgres' + ($matches | Where-Object CompletionText -eq 'postgres').ToolTip | Should -Match '5432' + } + + It "Completes database names through the tunnel alias" { + (Get-Completions 'tunnel alpha my').CompletionText | Should -Contain 'mysql' + } + + It "Completes nothing (and prints nothing) when config is missing" { + $emptyHome = Join-Path $env:TEMP "pester-completers-empty-$(Get-Random)" + New-Item -Path $emptyHome -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $emptyHome + try { + $names = (Get-Completions 'Connect-SSH ').CompletionText + $names | Should -Not -Contain 'alpha' + } finally { + $env:PSDT_HOME = $script:tkHome + Remove-Item $emptyHome -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/tests/Connect-SSH.Tests.ps1 b/tests/Connect-SSH.Tests.ps1 new file mode 100644 index 0000000..4d87b04 --- /dev/null +++ b/tests/Connect-SSH.Tests.ps1 @@ -0,0 +1,61 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + Import-Module $moduleDir -Force -DisableNameChecking + $script:savedPsdtHome = $env:PSDT_HOME +} + +AfterAll { + $env:PSDT_HOME = $script:savedPsdtHome +} + +Describe "Connect-SSH and Connect-SSHTunnel without config" { + BeforeEach { + $script:tkHome = Join-Path $env:TEMP "pester-ssh-noconfig-$(Get-Random)" + New-Item -Path $script:tkHome -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $script:tkHome + } + + AfterEach { + Remove-Item $script:tkHome -Recurse -Force -ErrorAction SilentlyContinue + } + + It "Connect-SSH tells the user to configure and does not try to connect" { + Mock -ModuleName PowerShellDevToolkit Test-WslAvailable { throw "must not probe WSL without config" } + $output = Connect-SSH myserver *>&1 | Out-String + $output | Should -Match 'Initialize-Toolkit' + $output | Should -Match 'configure config.json' + } + + It "Connect-SSHTunnel tells the user to configure and does not try to connect" { + Mock -ModuleName PowerShellDevToolkit Test-WslAvailable { throw "must not probe WSL without config" } + $output = Connect-SSHTunnel myserver postgres *>&1 | Out-String + $output | Should -Match 'Initialize-Toolkit' + $output | Should -Match 'configure config.json' + } +} + +Describe "Connect-SSH with config but missing credential file" { + BeforeEach { + $script:tkHome = Join-Path $env:TEMP "pester-ssh-nocred-$(Get-Random)" + New-Item -Path $script:tkHome -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $script:tkHome + Set-Content (Join-Path $script:tkHome 'config.json') '{ "ssh": { "servers": { "box": { "hostname": "box.example.com" } } } }' + Mock -ModuleName PowerShellDevToolkit Test-WslAvailable { $true } + } + + AfterEach { + Remove-Item $script:tkHome -Recurse -Force -ErrorAction SilentlyContinue + } + + It "Stops with the credential-file message before any connection attempt" { + $output = Connect-SSH box *>&1 | Out-String + $output | Should -Match 'Credential file not found' + $output | Should -Match ([regex]::Escape((Join-Path $script:tkHome 'creds'))) + } + + It "Tunnel stops with the credential-file message before any connection attempt" { + $output = Connect-SSHTunnel box 5432 *>&1 | Out-String + $output | Should -Match 'Credential file not found' + } +} diff --git a/tests/Get-ScriptConfig.Tests.ps1 b/tests/Get-ScriptConfig.Tests.ps1 index 5f97cf3..8e42be9 100644 --- a/tests/Get-ScriptConfig.Tests.ps1 +++ b/tests/Get-ScriptConfig.Tests.ps1 @@ -4,12 +4,13 @@ BeforeAll { Import-Module $moduleDir -Force $configPath = Join-Path $repoRoot "config.json" - $examplePath = Join-Path $repoRoot "config.example.json" + $examplePath = Join-Path $moduleDir "config.example.json" $script:hadConfig = Test-Path $configPath if (-not $script:hadConfig -and (Test-Path $examplePath)) { Copy-Item $examplePath $configPath $script:createdConfig = $true } + $script:savedPsdtHome = $env:PSDT_HOME } AfterAll { @@ -17,6 +18,7 @@ AfterAll { $configPath = Join-Path $repoRoot "config.json" Remove-Item $configPath -ErrorAction SilentlyContinue } + $env:PSDT_HOME = $script:savedPsdtHome } Describe "Get-ScriptConfig" { @@ -41,20 +43,51 @@ Describe "Get-ScriptConfig" { $config.editor | Should -Not -BeNullOrEmpty } - It "Should handle malformed JSON gracefully" { - $tempDir = Join-Path $env:TEMP "pester-config-$(Get-Random)" - New-Item -Path $tempDir -ItemType Directory -Force | Out-Null - try { - New-Item -Path "$tempDir\PowerShellDevToolkit" -ItemType Directory -Force | Out-Null - Copy-Item "$moduleDir\PowerShellDevToolkit.psm1" "$tempDir\PowerShellDevToolkit\" - Copy-Item "$moduleDir\PowerShellDevToolkit.psd1" "$tempDir\PowerShellDevToolkit\" - Copy-Item "$moduleDir\Private" "$tempDir\PowerShellDevToolkit\Private" -Recurse - Copy-Item "$moduleDir\Public" "$tempDir\PowerShellDevToolkit\Public" -Recurse - Set-Content "$tempDir\config.json" "NOT VALID JSON {{{{" - $output = pwsh -NoProfile -Command "Import-Module '$tempDir\PowerShellDevToolkit' -Force; `$r = & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig } 2>`$null; if (`$null -eq `$r) { 'NULL' } else { 'NOTNULL' }" - ($output -match 'NULL') | Should -Be $true - } finally { - Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + Context "When config is missing or invalid" { + BeforeEach { + $script:tempHome = Join-Path $env:TEMP "pester-config-$(Get-Random)" + New-Item -Path $script:tempHome -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $script:tempHome + } + + AfterEach { + $env:PSDT_HOME = $script:savedPsdtHome + Remove-Item $script:tempHome -Recurse -Force -ErrorAction SilentlyContinue + } + + It "Should return null for malformed JSON" { + Set-Content (Join-Path $script:tempHome "config.json") "NOT VALID JSON {{{{" + $result = & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig } 6>$null + $result | Should -BeNullOrEmpty + } + + It "Should tell the user where it looked and to run Initialize-Toolkit when config is missing" { + $output = & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig } *>&1 | Out-String + $output | Should -Match 'Configuration file not found' + $output | Should -Match ([regex]::Escape($script:tempHome)) + $output | Should -Match 'Initialize-Toolkit' + } + + It "Should not prompt with Read-Host when config is missing" { + Mock -ModuleName PowerShellDevToolkit Read-Host { throw "Read-Host must not be called" } + { & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig } *>&1 | Out-Null } | Should -Not -Throw + } + + It "Should print nothing with -Quiet when config is missing" { + $output = & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig -Quiet } *>&1 | Out-String + $output.Trim() | Should -BeNullOrEmpty + } + + It "Should print nothing with -Quiet for malformed JSON" { + Set-Content (Join-Path $script:tempHome "config.json") "NOT VALID JSON {{{{" + $output = & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig -Quiet } *>&1 | Out-String + $output.Trim() | Should -BeNullOrEmpty + } + + It "Should load config.json from PSDT_HOME" { + Set-Content (Join-Path $script:tempHome "config.json") '{ "editor": { "notepadPlusPlus": "X:\\npp.exe" } }' + $config = & (Get-Module PowerShellDevToolkit) { Get-ScriptConfig } + $config.editor.notepadPlusPlus | Should -Be 'X:\npp.exe' } } } diff --git a/tests/Get-ServiceStatus.Tests.ps1 b/tests/Get-ServiceStatus.Tests.ps1 index 853fa82..70c4702 100644 --- a/tests/Get-ServiceStatus.Tests.ps1 +++ b/tests/Get-ServiceStatus.Tests.ps1 @@ -4,7 +4,7 @@ BeforeAll { Import-Module $moduleDir -Force $configPath = Join-Path $repoRoot "config.json" - $examplePath = Join-Path $repoRoot "config.example.json" + $examplePath = Join-Path $moduleDir "config.example.json" $script:hadConfig = Test-Path $configPath if (-not $script:hadConfig -and (Test-Path $examplePath)) { Copy-Item $examplePath $configPath diff --git a/tests/Get-ToolkitPaths.Tests.ps1 b/tests/Get-ToolkitPaths.Tests.ps1 new file mode 100644 index 0000000..38a44bc --- /dev/null +++ b/tests/Get-ToolkitPaths.Tests.ps1 @@ -0,0 +1,76 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + Import-Module $moduleDir -Force -DisableNameChecking + $script:savedPsdtHome = $env:PSDT_HOME +} + +AfterAll { + $env:PSDT_HOME = $script:savedPsdtHome +} + +Describe "Get-ToolkitPaths" { + BeforeEach { + $env:PSDT_HOME = $null + } + + It "Reports a Git install when running from the repo checkout" { + $p = & (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths } + $p.InstallType | Should -Be 'Git' + $p.ModuleRoot | Should -Be $moduleDir + $p.RepoRoot | Should -Be $repoRoot + $p.DataRoot | Should -Be $repoRoot + } + + It "Derives config, creds, stamp and example paths from the roots" { + $p = & (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths } + $p.ConfigPath | Should -Be (Join-Path $repoRoot 'config.json') + $p.CredsPath | Should -Be (Join-Path $repoRoot 'creds') + $p.UpdateStampPath | Should -Be (Join-Path $repoRoot '.last-update-check') + $p.ExampleConfigPath | Should -Be (Join-Path $moduleDir 'config.example.json') + } + + It "Honors PSDT_HOME for the data folder without changing the install type" { + $env:PSDT_HOME = Join-Path $env:TEMP "pester-psdt-home-$(Get-Random)" + $p = & (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths } + $p.InstallType | Should -Be 'Git' + $p.DataRoot | Should -Be $env:PSDT_HOME + $p.ConfigPath | Should -Be (Join-Path $env:PSDT_HOME 'config.json') + $p.CredsPath | Should -Be (Join-Path $env:PSDT_HOME 'creds') + } + + It "Re-evaluates PSDT_HOME on every call" { + $env:PSDT_HOME = 'C:\psdt-first' + (& (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths }).DataRoot | Should -Be 'C:\psdt-first' + $env:PSDT_HOME = 'C:\psdt-second' + (& (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths }).DataRoot | Should -Be 'C:\psdt-second' + } + + It "Ignores a whitespace-only PSDT_HOME" { + $env:PSDT_HOME = ' ' + (& (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths }).DataRoot | Should -Be $repoRoot + } + + It "Falls back to LocalAppData for a Gallery-style install" { + $tempDir = Join-Path $env:TEMP "pester-gallery-$(Get-Random)" + $version = (Import-PowerShellDataFile (Join-Path $moduleDir 'PowerShellDevToolkit.psd1')).ModuleVersion + $target = Join-Path $tempDir "Modules\PowerShellDevToolkit\$version" + New-Item -Path $target -ItemType Directory -Force | Out-Null + Copy-Item "$moduleDir\*" $target -Recurse -Force + try { + $cmd = "`$env:PSDT_HOME = `$null; `$env:PSDT_SKIP_UPDATE_CHECK = '1'; " + + "Import-Module '$target\PowerShellDevToolkit.psd1' -Force -DisableNameChecking; " + + "`$p = & (Get-Module PowerShellDevToolkit) { Get-ToolkitPaths }; " + + "`$p.InstallType; `$p.DataRoot; [string]`$p.RepoRoot; `$p.ModuleRoot" + $output = @(pwsh -NoProfile -NonInteractive -Command $cmd) + $output[0] | Should -Be 'Gallery' + $output[1] | Should -Be (Join-Path $env:LOCALAPPDATA 'PowerShellDevToolkit') + $output[2] | Should -BeNullOrEmpty + # Compare the tail, not the exact path: some runners hand out an 8.3 + # short-form TEMP (RUNNER~1) while $PSScriptRoot resolves to the long form. + $output[3] | Should -BeLike "*\Modules\PowerShellDevToolkit\$version" + } finally { + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/tests/Initialize-Toolkit.Tests.ps1 b/tests/Initialize-Toolkit.Tests.ps1 new file mode 100644 index 0000000..5765ab2 --- /dev/null +++ b/tests/Initialize-Toolkit.Tests.ps1 @@ -0,0 +1,78 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + Import-Module $moduleDir -Force -DisableNameChecking + $script:savedPsdtHome = $env:PSDT_HOME + $script:examplePath = Join-Path $moduleDir "config.example.json" +} + +AfterAll { + $env:PSDT_HOME = $script:savedPsdtHome +} + +Describe "Initialize-Toolkit" { + BeforeEach { + $script:tkHome = Join-Path $env:TEMP "pester-init-$(Get-Random)" + $env:PSDT_HOME = $script:tkHome + $script:configPath = Join-Path $script:tkHome "config.json" + Mock -ModuleName PowerShellDevToolkit Edit-File { } + } + + AfterEach { + Remove-Item $script:tkHome -Recurse -Force -ErrorAction SilentlyContinue + } + + It "Should be exported from the module" { + (Get-Command Initialize-Toolkit -Module PowerShellDevToolkit -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty + } + + It "Should create the data folder, creds folder and config.json" { + Initialize-Toolkit -NoOpen *>&1 | Out-Null + Test-Path $script:tkHome | Should -Be $true + Test-Path (Join-Path $script:tkHome 'creds') | Should -Be $true + Test-Path $script:configPath | Should -Be $true + } + + It "Should create a config identical to the example" { + Initialize-Toolkit -NoOpen *>&1 | Out-Null + (Get-Content $script:configPath -Raw) | Should -Be (Get-Content $script:examplePath -Raw) + } + + It "Should leave an existing config alone without -Force" { + New-Item -Path $script:tkHome -ItemType Directory -Force | Out-Null + Set-Content $script:configPath '{ "custom": true }' + $output = Initialize-Toolkit -NoOpen *>&1 | Out-String + (Get-Content $script:configPath -Raw) | Should -Match 'custom' + $output | Should -Match 'already exists' + } + + It "Should replace an existing config with -Force" { + New-Item -Path $script:tkHome -ItemType Directory -Force | Out-Null + Set-Content $script:configPath '{ "custom": true }' + Initialize-Toolkit -NoOpen -Force *>&1 | Out-Null + (Get-Content $script:configPath -Raw) | Should -Not -Match 'custom' + (Get-Content $script:configPath -Raw) | Should -Match '"ssh"' + } + + It "Should report the resolved paths and next steps" { + $output = Initialize-Toolkit -NoOpen *>&1 | Out-String + $output | Should -Match ([regex]::Escape($script:tkHome)) + $output | Should -Match 'Git' + $output | Should -Match 'New-SSHCredential' + } + + It "Should not open an editor with -NoOpen" { + Initialize-Toolkit -NoOpen *>&1 | Out-Null + Should -Invoke -ModuleName PowerShellDevToolkit Edit-File -Times 0 -Exactly + } + + It "Should open the config in the editor by default" { + Initialize-Toolkit *>&1 | Out-Null + Should -Invoke -ModuleName PowerShellDevToolkit Edit-File -Times 1 -Exactly + } + + It "Should not prompt with Read-Host" { + Mock -ModuleName PowerShellDevToolkit Read-Host { throw "Read-Host must not be called" } + { Initialize-Toolkit -NoOpen *>&1 | Out-Null } | Should -Not -Throw + } +} diff --git a/tests/Module.Tests.ps1 b/tests/Module.Tests.ps1 new file mode 100644 index 0000000..d82f8a3 --- /dev/null +++ b/tests/Module.Tests.ps1 @@ -0,0 +1,48 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + $manifestPath = Join-Path $moduleDir "PowerShellDevToolkit.psd1" + Import-Module $moduleDir -Force -DisableNameChecking +} + +Describe "Module package" { + It "Ships config.example.json inside the module folder" { + Test-Path (Join-Path $moduleDir "config.example.json") | Should -Be $true + } + + It "Does not keep a second copy of config.example.json at the repo root" { + Test-Path (Join-Path $repoRoot "config.example.json") | Should -Be $false + } + + It "Passes Test-ModuleManifest" { + { Test-ModuleManifest $manifestPath -ErrorAction Stop | Out-Null } | Should -Not -Throw + } + + It "Exports every function listed in the manifest" { + $manifest = Import-PowerShellDataFile $manifestPath + foreach ($fn in $manifest.FunctionsToExport) { + (Get-Command $fn -Module PowerShellDevToolkit -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty -Because "$fn is listed in FunctionsToExport" + } + } + + It "Defines every alias listed in the manifest" { + $manifest = Import-PowerShellDataFile $manifestPath + foreach ($alias in $manifest.AliasesToExport) { + (Get-Alias $alias -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty -Because "$alias is listed in AliasesToExport" + } + } + + It "Is version 1.2.0" { + (Import-PowerShellDataFile $manifestPath).ModuleVersion | Should -Be '1.2.0' + } + + It "Has release notes for 1.2.0" { + $notes = (Import-PowerShellDataFile $manifestPath).PrivateData.PSData.ReleaseNotes + $notes | Should -Match '1\.2\.0' + $notes | Should -Match 'Initialize-Toolkit' + } + + It "Has a CHANGELOG entry for 1.2.0" { + (Get-Content (Join-Path $repoRoot "CHANGELOG.md") -Raw) | Should -Match '## \[1\.2\.0\]' + } +} diff --git a/tests/New-SSHCredential.Tests.ps1 b/tests/New-SSHCredential.Tests.ps1 new file mode 100644 index 0000000..3160898 --- /dev/null +++ b/tests/New-SSHCredential.Tests.ps1 @@ -0,0 +1,100 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + Import-Module $moduleDir -Force -DisableNameChecking + $script:savedPsdtHome = $env:PSDT_HOME + + function New-TestCredential { + param([string]$User, [string]$Password) + New-Object System.Management.Automation.PSCredential($User, (ConvertTo-SecureString $Password -AsPlainText -Force)) + } +} + +AfterAll { + $env:PSDT_HOME = $script:savedPsdtHome +} + +Describe "New-SSHCredential" { + BeforeEach { + $script:tkHome = Join-Path $env:TEMP "pester-sshcred-$(Get-Random)" + New-Item -Path $script:tkHome -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $script:tkHome + $script:cred = New-TestCredential -User 'deploy' -Password 'p@ss w0rd' + $script:defaultFile = Join-Path $script:tkHome 'creds\ssh-credentials.xml' + } + + AfterEach { + Remove-Item $script:tkHome -Recurse -Force -ErrorAction SilentlyContinue + } + + It "Should be exported from the module" { + (Get-Command New-SSHCredential -Module PowerShellDevToolkit -ErrorAction SilentlyContinue) | Should -Not -BeNullOrEmpty + } + + It "Should write a credential file that round-trips through Import-Clixml" { + New-SSHCredential -Credential $script:cred *>&1 | Out-Null + Test-Path $script:defaultFile | Should -Be $true + $loaded = Import-Clixml $script:defaultFile + $loaded.UserName | Should -Be 'deploy' + $loaded.GetNetworkCredential().Password | Should -Be 'p@ss w0rd' + } + + It "Should create the creds folder when it does not exist" { + Test-Path (Join-Path $script:tkHome 'creds') | Should -Be $false + New-SSHCredential -Credential $script:cred *>&1 | Out-Null + Test-Path (Join-Path $script:tkHome 'creds') | Should -Be $true + } + + It "Should use ssh.credentialFile from config.json as the default file name" { + Set-Content (Join-Path $script:tkHome 'config.json') '{ "ssh": { "credentialFile": "custom-creds.xml" } }' + New-SSHCredential -Credential $script:cred *>&1 | Out-Null + Test-Path (Join-Path $script:tkHome 'creds\custom-creds.xml') | Should -Be $true + } + + It "Should honor -FileName" { + New-SSHCredential -Credential $script:cred -FileName 'prod.xml' *>&1 | Out-Null + Test-Path (Join-Path $script:tkHome 'creds\prod.xml') | Should -Be $true + } + + It "Should refuse to overwrite an existing file without -Force" { + New-SSHCredential -Credential $script:cred *>&1 | Out-Null + $other = New-TestCredential -User 'other' -Password 'x' + $output = New-SSHCredential -Credential $other *>&1 | Out-String + $output | Should -Match 'already exists' + (Import-Clixml $script:defaultFile).UserName | Should -Be 'deploy' + } + + It "Should overwrite with -Force" { + New-SSHCredential -Credential $script:cred *>&1 | Out-Null + $other = New-TestCredential -User 'other' -Password 'x' + New-SSHCredential -Credential $other -Force *>&1 | Out-Null + (Import-Clixml $script:defaultFile).UserName | Should -Be 'other' + } + + It "Should report the saved path and user" { + $output = New-SSHCredential -Credential $script:cred *>&1 | Out-String + $output | Should -Match 'deploy' + $output | Should -Match ([regex]::Escape($script:defaultFile)) + } + + It "Should not prompt when -Credential is supplied" { + Mock -ModuleName PowerShellDevToolkit Get-Credential { throw "Get-Credential must not be called" } + { New-SSHCredential -Credential $script:cred *>&1 | Out-Null } | Should -Not -Throw + } + + It "Should prompt with Get-Credential when no credential is supplied" { + Mock -ModuleName PowerShellDevToolkit Get-Credential { + New-Object System.Management.Automation.PSCredential('prompted', (ConvertTo-SecureString 'pw' -AsPlainText -Force)) + } + New-SSHCredential *>&1 | Out-Null + Should -Invoke -ModuleName PowerShellDevToolkit Get-Credential -Times 1 -Exactly + (Import-Clixml $script:defaultFile).UserName | Should -Be 'prompted' + } + + It "Should write nothing when the prompt is cancelled" { + Mock -ModuleName PowerShellDevToolkit Get-Credential { $null } + $output = New-SSHCredential *>&1 | Out-String + Test-Path $script:defaultFile | Should -Be $false + $output | Should -Match 'Cancelled' + } +} diff --git a/tests/Resolve-SSHTarget.Tests.ps1 b/tests/Resolve-SSHTarget.Tests.ps1 new file mode 100644 index 0000000..11fc238 --- /dev/null +++ b/tests/Resolve-SSHTarget.Tests.ps1 @@ -0,0 +1,122 @@ +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + Import-Module $moduleDir -Force -DisableNameChecking + $script:savedPsdtHome = $env:PSDT_HOME + + $script:tkHome = Join-Path $env:TEMP "pester-resolve-$(Get-Random)" + $script:credsDir = Join-Path $script:tkHome 'creds' + New-Item -Path $script:credsDir -ItemType Directory -Force | Out-Null + $env:PSDT_HOME = $script:tkHome + + $script:keyPath = Join-Path $script:credsDir 'aws.pem' + Set-Content $script:keyPath "-----BEGIN FAKE KEY-----" + + $cred = New-Object System.Management.Automation.PSCredential('creduser', (ConvertTo-SecureString 'secret' -AsPlainText -Force)) + $cred | Export-Clixml (Join-Path $script:credsDir 'ssh-credentials.xml') + + $script:config = @" +{ + "ssh": { + "credentialFile": "ssh-credentials.xml", + "servers": { + "keyed": { "hostname": "keyed.example.com", "keyFile": "aws.pem", "user": "ec2-user" }, + "keyonly": { "hostname": "keyonly.example.com", "keyFile": "aws.pem" }, + "pw": { "hostname": "pw.example.com" }, + "missingkey": { "hostname": "mk.example.com", "keyFile": "nope.pem" }, + "absolute": { "hostname": "abs.example.com", "keyFile": "$($script:keyPath -replace '\\', '\\')", "user": "root" } + } + } +} +"@ | ConvertFrom-Json + + function Resolve-Target { + param([string]$Target, $Config) + & (Get-Module PowerShellDevToolkit) { param($t, $c) Resolve-SSHTarget -Target $t -Config $c } $Target $Config + } +} + +AfterAll { + $env:PSDT_HOME = $script:savedPsdtHome + Remove-Item $script:tkHome -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe "Resolve-SSHTarget" { + It "Resolves an alias with a key file and explicit user" { + $r = Resolve-Target -Target 'keyed' -Config $script:config + $r.Server | Should -Be 'keyed.example.com' + $r.UserName | Should -Be 'ec2-user' + $r.KeyFilePath | Should -Be $script:keyPath + $r.Password | Should -BeNullOrEmpty + } + + It "Falls back to the credential file username for key auth without a user" { + $r = Resolve-Target -Target 'keyonly' -Config $script:config + $r.UserName | Should -Be 'creduser' + $r.KeyFilePath | Should -Be $script:keyPath + } + + It "Loads username and password from the credential file for password auth" { + $r = Resolve-Target -Target 'pw' -Config $script:config + $r.Server | Should -Be 'pw.example.com' + $r.UserName | Should -Be 'creduser' + $r.Password | Should -Be 'secret' + $r.Credential | Should -Not -BeNullOrEmpty + $r.KeyFilePath | Should -BeNullOrEmpty + } + + It "Treats an unknown target as a raw hostname" { + $r = Resolve-Target -Target 'raw.example.com' -Config $script:config + $r.Server | Should -Be 'raw.example.com' + $r.UserName | Should -Be 'creduser' + } + + It "Accepts an absolute key file path" { + $r = Resolve-Target -Target 'absolute' -Config $script:config + $r.KeyFilePath | Should -Be $script:keyPath + $r.UserName | Should -Be 'root' + } + + It "Returns null and reports a missing key file" { + $output = Resolve-Target -Target 'missingkey' -Config $script:config *>&1 | Out-String + $r = Resolve-Target -Target 'missingkey' -Config $script:config 6>$null + $r | Should -BeNullOrEmpty + $output | Should -Match 'Key file not found' + $output | Should -Match 'nope\.pem' + } + + It "Returns null and reports a missing credential file" { + $cfg = '{ "ssh": { "credentialFile": "nope.xml", "servers": { "pw": { "hostname": "pw.example.com" } } } }' | ConvertFrom-Json + $output = Resolve-Target -Target 'pw' -Config $cfg *>&1 | Out-String + $r = Resolve-Target -Target 'pw' -Config $cfg 6>$null + $r | Should -BeNullOrEmpty + $output | Should -Match 'Credential file not found' + } + + It "Returns null when key auth has no username anywhere" { + $cfg = '{ "ssh": { "servers": { "keyonly": { "hostname": "k.example.com", "keyFile": "aws.pem" } } } }' | ConvertFrom-Json + $output = Resolve-Target -Target 'keyonly' -Config $cfg *>&1 | Out-String + $r = Resolve-Target -Target 'keyonly' -Config $cfg 6>$null + $r | Should -BeNullOrEmpty + $output | Should -Match 'Username required' + } + + It "Defaults the credential file name to ssh-credentials.xml" { + $cfg = '{ "ssh": { "servers": { "pw": { "hostname": "pw.example.com" } } } }' | ConvertFrom-Json + $r = Resolve-Target -Target 'pw' -Config $cfg + $r.UserName | Should -Be 'creduser' + } +} + +Describe "Test-WslAvailable" { + It "Returns a boolean" { + $r = & (Get-Module PowerShellDevToolkit) { Test-WslAvailable } + $r | Should -BeOfType [bool] + } + + It "Returns false when wsl is not on the path" { + Mock -ModuleName PowerShellDevToolkit Get-Command { $null } -ParameterFilter { $Name -eq 'wsl' } + $r = & (Get-Module PowerShellDevToolkit) { Test-WslAvailable } + $r | Should -Be $false + } +} diff --git a/tests/Start-DevServer.Tests.ps1 b/tests/Start-DevServer.Tests.ps1 index 8a34fcc..0b71d0f 100644 --- a/tests/Start-DevServer.Tests.ps1 +++ b/tests/Start-DevServer.Tests.ps1 @@ -4,7 +4,7 @@ BeforeAll { Import-Module $moduleDir -Force $configPath = Join-Path $repoRoot "config.json" - $examplePath = Join-Path $repoRoot "config.example.json" + $examplePath = Join-Path $moduleDir "config.example.json" $script:hadConfig = Test-Path $configPath if (-not $script:hadConfig -and (Test-Path $examplePath)) { Copy-Item $examplePath $configPath diff --git a/tests/Update-Toolkit.Tests.ps1 b/tests/Update-Toolkit.Tests.ps1 index d434965..e02489a 100644 --- a/tests/Update-Toolkit.Tests.ps1 +++ b/tests/Update-Toolkit.Tests.ps1 @@ -57,3 +57,143 @@ Describe "Set-ToolkitUpdateTimestamp" { Remove-Item $stampFile -ErrorAction SilentlyContinue } } + +Describe "Update-Toolkit on a Gallery install" { + BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + $global:PesterGalleryHome = Join-Path $env:TEMP "pester-gallery-update-$(Get-Random)" + New-Item -Path $global:PesterGalleryHome -ItemType Directory -Force | Out-Null + $global:PesterGalleryModuleDir = $moduleDir + $global:PesterInstalledVersion = (Import-PowerShellDataFile (Join-Path $moduleDir "PowerShellDevToolkit.psd1")).ModuleVersion + } + + AfterAll { + Remove-Item $global:PesterGalleryHome -Recurse -Force -ErrorAction SilentlyContinue + Remove-Variable -Name PesterGalleryHome, PesterGalleryModuleDir, PesterInstalledVersion -Scope Global -ErrorAction SilentlyContinue + } + + BeforeEach { + Mock -ModuleName PowerShellDevToolkit Get-ToolkitPaths { + [pscustomobject]@{ + InstallType = 'Gallery' + ModuleRoot = $global:PesterGalleryModuleDir + RepoRoot = $null + DataRoot = $global:PesterGalleryHome + ConfigPath = Join-Path $global:PesterGalleryHome 'config.json' + ExampleConfigPath = Join-Path $global:PesterGalleryModuleDir 'config.example.json' + CredsPath = Join-Path $global:PesterGalleryHome 'creds' + UpdateStampPath = Join-Path $global:PesterGalleryHome '.last-update-check' + } + } + Mock -ModuleName PowerShellDevToolkit Read-Host { throw "Read-Host must not be called with -CheckOnly" } + Mock -ModuleName PowerShellDevToolkit Update-Module { throw "Update-Module must not be called with -CheckOnly" } + Remove-Item (Join-Path $global:PesterGalleryHome '.last-update-check') -ErrorAction SilentlyContinue + } + + It "Reports up to date when the Gallery version matches" { + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = $global:PesterInstalledVersion; ReleaseNotes = $null } } + $output = Update-Toolkit -CheckOnly *>&1 | Out-String + $output | Should -Match 'up to date' + $output | Should -Match 'Gallery' + $output | Should -Match ([regex]::Escape($global:PesterInstalledVersion)) + } + + It "Reports an available update with release notes" { + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = '99.0.0'; ReleaseNotes = 'Big new things' } } + $output = Update-Toolkit -CheckOnly *>&1 | Out-String + $output | Should -Match 'Update available' + $output | Should -Match '99\.0\.0' + $output | Should -Match 'Big new things' + } + + It "Reports when the Gallery cannot be reached" { + Mock -ModuleName PowerShellDevToolkit Find-Module { throw "no network" } + $output = Update-Toolkit -CheckOnly *>&1 | Out-String + $output | Should -Match 'Could not reach the PowerShell Gallery' + } + + It "Does not mention git on a Gallery install" { + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = $global:PesterInstalledVersion; ReleaseNotes = $null } } + $output = Update-Toolkit -CheckOnly *>&1 | Out-String + $output | Should -Not -Match 'not a git repository' + } + + It "Never prompts or installs with -CheckOnly" { + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = '99.0.0'; ReleaseNotes = $null } } + { Update-Toolkit -CheckOnly *>&1 | Out-Null } | Should -Not -Throw + } +} + +Describe "Test-ToolkitUpdate on a Gallery install" { + BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) + $moduleDir = Join-Path $repoRoot "PowerShellDevToolkit" + $global:PesterGalleryHome = Join-Path $env:TEMP "pester-gallery-check-$(Get-Random)" + New-Item -Path $global:PesterGalleryHome -ItemType Directory -Force | Out-Null + $global:PesterGalleryModuleDir = $moduleDir + $global:PesterStampPath = Join-Path $global:PesterGalleryHome '.last-update-check' + } + + AfterAll { + Remove-Item $global:PesterGalleryHome -Recurse -Force -ErrorAction SilentlyContinue + Remove-Variable -Name PesterGalleryHome, PesterGalleryModuleDir, PesterStampPath -Scope Global -ErrorAction SilentlyContinue + } + + BeforeEach { + Mock -ModuleName PowerShellDevToolkit Get-ToolkitPaths { + [pscustomobject]@{ + InstallType = 'Gallery' + ModuleRoot = $global:PesterGalleryModuleDir + RepoRoot = $null + DataRoot = $global:PesterGalleryHome + ConfigPath = Join-Path $global:PesterGalleryHome 'config.json' + ExampleConfigPath = Join-Path $global:PesterGalleryModuleDir 'config.example.json' + CredsPath = Join-Path $global:PesterGalleryHome 'creds' + UpdateStampPath = $global:PesterStampPath + } + } + Remove-Item $global:PesterStampPath -ErrorAction SilentlyContinue + } + + It "Prints the update hint when a newer version exists" { + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = '99.0.0'; ReleaseNotes = $null } } + $output = Test-ToolkitUpdate *>&1 | Out-String + $output | Should -Match 'Update-Toolkit' + $output | Should -Match '99\.0\.0' + } + + It "Prints nothing when up to date" { + $installed = (Import-PowerShellDataFile (Join-Path $global:PesterGalleryModuleDir "PowerShellDevToolkit.psd1")).ModuleVersion + $global:PesterInstalledVersion = $installed + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = $global:PesterInstalledVersion; ReleaseNotes = $null } } + $output = Test-ToolkitUpdate *>&1 | Out-String + $output.Trim() | Should -BeNullOrEmpty + } + + It "Writes the stamp file after checking" { + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = '0.0.1'; ReleaseNotes = $null } } + Test-ToolkitUpdate *>&1 | Out-Null + Test-Path $global:PesterStampPath | Should -Be $true + } + + It "Creates the data folder for the stamp when it does not exist" { + Remove-Item $global:PesterGalleryHome -Recurse -Force -ErrorAction SilentlyContinue + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = '0.0.1'; ReleaseNotes = $null } } + Test-ToolkitUpdate *>&1 | Out-Null + Test-Path $global:PesterStampPath | Should -Be $true + } + + It "Skips the Gallery call when the stamp is fresh" { + New-Item -Path $global:PesterGalleryHome -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText($global:PesterStampPath, (Get-Date -Format 'o')) + Mock -ModuleName PowerShellDevToolkit Find-Module { [pscustomobject]@{ Version = '99.0.0'; ReleaseNotes = $null } } + Test-ToolkitUpdate *>&1 | Out-Null + Should -Invoke -ModuleName PowerShellDevToolkit Find-Module -Times 0 -Exactly + } + + It "Never throws when the Gallery is unreachable" { + Mock -ModuleName PowerShellDevToolkit Find-Module { throw "no network" } + { Test-ToolkitUpdate *>&1 | Out-Null } | Should -Not -Throw + } +} diff --git a/tests/helpme.Tests.ps1 b/tests/helpme.Tests.ps1 index 2abf99f..990ff99 100644 --- a/tests/helpme.Tests.ps1 +++ b/tests/helpme.Tests.ps1 @@ -34,4 +34,11 @@ Describe "helpme" { ($output -match 'ai-rules') | Should -Be $true ($output -match 'context') | Should -Be $true } + + It "Should -Contain toolkit setup commands and the config path" { + $output = Show-Help *>&1 | Out-String + ($output -match 'Initialize-Toolkit') | Should -Be $true + ($output -match 'New-SSHCredential') | Should -Be $true + ($output -match 'config\.json') | Should -Be $true + } }