diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1ed5b7b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/devcontainers/features/powershell:1": { + "version": "latest", + "modules": "Pester,PSScriptAnalyzer" + } + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..65e89de --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,24 @@ +# PSSpecKit Development Guidelines + +Auto-generated from all feature plans. Last updated: 2025-10-02 + +## Active Technologies +- PowerShell 7.x (Core-compatible) + PSSpecKit module (`PSSpecKit.psm1`), Pester v5, PSScriptAnalyzer baseline rules (feat/paramsets-install-speckit) + +## Project Structure +``` +src/ +tests/ +``` + +## Commands +# Add commands for PowerShell 7.x (Core-compatible) + +## Code Style +PowerShell 7.x (Core-compatible): Follow standard conventions + +## Recent Changes +- feat/paramsets-install-speckit: Added PowerShell 7.x (Core-compatible) + PSSpecKit module (`PSSpecKit.psm1`), Pester v5, PSScriptAnalyzer baseline rules + + + \ No newline at end of file diff --git a/.github/workflows/powershell-ci.yml b/.github/workflows/powershell-ci.yml index 7d09028..6a1b699 100644 --- a/.github/workflows/powershell-ci.yml +++ b/.github/workflows/powershell-ci.yml @@ -4,8 +4,8 @@ on: push: branches: - 'v1_specsdev' - - '001-create-a-powershell' - 'feature/**' + - 'feat/**' pull_request: branches: - 'v1_specsdev' @@ -14,26 +14,33 @@ on: jobs: lint-and-test: runs-on: windows-latest - steps: + steps: - uses: actions/checkout@v4 + - name: List tests directory contents + run: | + ls -l tests + - name: Install PSScriptAnalyzer and Pester run: | - pwsh -Command "Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -AcceptLicense" - pwsh -Command "Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -Scope CurrentUser -AcceptLicense" + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -AcceptLicense + Install-Module -Name Pester -MinimumVersion 5.0.0 -Force -Scope CurrentUser -AcceptLicense - name: Enforce no absolute paths in scripts run: | pwsh -NoProfile -Command "Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; ./scripts/check-absolute-paths.ps1 -Path ." - - name: Run PSScriptAnalyzer + - name: Run PSScriptAnalyzer on module + run: | + pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path ./PSSpecKit -Recurse -Settings .psscriptanalyzer.psd1" + + - name: Verify module can be imported run: | - pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings .psscriptanalyzer.psd1" + pwsh -NoProfile -Command "Import-Module ./PSSpecKit/PSSpecKit.psd1 -Force; Write-Host 'Module imported successfully'; Get-Command -Module PSSpecKit | Format-Table -AutoSize" - name: Run Pester tests run: | - pwsh -NoProfile -Command 'Import-Module Pester -RequiredVersion 5.0.0 -Force; $r = Invoke-Pester -Path ./tests -PassThru; if ($r.FailedCount -gt 0) { exit 1 }' - # pwsh -NoProfile -Command "Import-Module Pester -RequiredVersion 5.0.0 -Force; $r = Pester\Invoke-Pester -Path .\tests -PassThru; if ($r.FailedCount -gt 0) { exit 1 }" + pwsh -NoProfile -Command 'Import-Module Pester -MinimumVersion 5.0.0 -Force; $r = Pester\Invoke-Pester -Path .\tests -PassThru; if ($r.FailedCount -gt 0) { exit 1 }' integration-tests: runs-on: windows-latest @@ -49,4 +56,4 @@ jobs: - name: Run integration tests only run: | - pwsh -NoProfile -Command "Import-Module Pester -RequiredVersion 5.0.0 -Force; $r = Pester\Invoke-Pester -Path .\tests\integration -PassThru; if ($r.FailedCount -gt 0) { exit 1 }" + pwsh -NoProfile -Command "Import-Module Pester -MinimumVersion 5.0.0 -Force; $r = Pester\Invoke-Pester -Path .\tests\integration -PassThru; if ($r.FailedCount -gt 0) { exit 1 }" diff --git a/.gitignore b/.gitignore index 3d664e0..cb96dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # PowerShell temporary files *.ps1xml +# Don't ignore module files +!PSSpecKit/**/*.psm1 +!PSSpecKit/**/*.psd1 *.psm1 *.psd1 *.psd1.updated diff --git a/.psscriptanalyzer.psd1 b/.psscriptanalyzer.psd1 index 723d65e..1a7c1ad 100644 --- a/.psscriptanalyzer.psd1 +++ b/.psscriptanalyzer.psd1 @@ -1,6 +1,9 @@ @{ # Basic PSScriptAnalyzer settings tuned for this project # Rules must be provided as a hashtable mapping rule names to settings + # NOTE: When running PSScriptAnalyzer, exclude the .specify directory as it contains + # internal tooling scripts that are not part of the application: + # Invoke-ScriptAnalyzer -Path . -Settings .psscriptanalyzer.psd1 -Recurse -ExcludePath .specify Rules = @{ PSUseApprovedVerbs = @{ Enable = $true } PSAvoidUsingPlainTextForPassword = @{ Enable = $true } diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index ba21e10..c309c23 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,21 +1,21 @@ # PSSpecKit Constitution @@ -23,11 +23,12 @@ Follow-up TODOs: ## Core Principles ### I. Code Quality & Style (PowerShell-centric) -All authored PowerShell code MUST follow Microsoft PowerShell best practices. This includes: -- Consistent, discoverable names following Verb-Noun cmdlet conventions (approved verbs from Microsoft). Module, function, and parameter names MUST be clear and purpose-driven. -- Script and module layout MUST follow common PowerShell module structure (ExportedFunctions, Public/Private separation, module manifest when applicable). -- Static analysis using PSScriptAnalyzer with a project baseline is REQUIRED; rules MAY be tightened per-module. Violations MUST be addressed before merging. -- Code MUST be idempotent where applicable and avoid implicit global state; side-effects MUST be explicit and documented. +All authored PowerShell code MUST meet Microsoft PowerShell scripting best practices and pass the project's PSScriptAnalyzer quality checks. This is a mandatory, CI-enforced gate. Specifically: +- All code MUST adhere to Microsoft-approved naming conventions (Verb-Noun) and use discoverable, purpose-driven names for modules, functions and parameters. +- Script and module layouts MUST follow common PowerShell module structure (ExportedFunctions, Public/Private separation, and a module manifest when applicable). +- Static analysis using PSScriptAnalyzer against a project baseline configuration is REQUIRED; module-level rules MAY be tightened. CI MUST fail if PSScriptAnalyzer violations are present and violations MUST be addressed before merging. +- All scripts and modules MUST document any accepted deviations from the baseline (with rationale) in the PR; exceptions are time-limited and require maintainer approval. +- Code MUST be idempotent where applicable, avoid implicit global state, and make side-effects explicit and documented. Path usage policy: NO absolute filesystem paths are permitted inside committed scripts or modules. All filesystem paths referenced by scripts MUST be relative to the script/module root and must be resolved at runtime using the script's location (for example, $PSScriptRoot) or a small, documented repository-root resolution helper called from the script root. Hard-coded absolute paths will fail review and MUST be removed before merge. @@ -73,4 +74,4 @@ The Constitution defines mandatory practices for development and review. Amendme - Versioning Policy: The Constitution uses semantic versioning: MAJOR for breaking governance changes (removals or redefinitions), MINOR for new principles or material expansions, PATCH for wording/clarity fixes. The author of the PR MUST indicate the expected bump and rationale. - Compliance: The `Constitution Check` step in `.specify/templates/plan-template.md` and related templates MUST be evaluated during planning. CI tooling and reviewers are responsible for enforcing gates. -**Version**: 1.0.0 | **Ratified**: 2025-10-01 | **Last Amended**: 2025-10-01 \ No newline at end of file +**Version**: 1.0.1 | **Ratified**: 2025-10-01 | **Last Amended**: 2025-10-02 \ No newline at end of file diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index d705bb9..30b5e89 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -152,7 +152,7 @@ directories captured above] - Quickstart test = story validation steps 5. **Update agent file incrementally** (O(1) operation): - - Run `.specify/scripts/powershell/update-agent-context.ps1 -AgentType windsurf` + - Run `.specify/scripts/powershell/update-agent-context.ps1 -AgentType copilot` **IMPORTANT**: Execute it exactly as specified above. Do not add or remove any arguments. - If exists: Add only NEW tech from current plan - Preserve manual additions between markers diff --git a/PR_BODY_chore-constitution-1.0.1.md b/PR_BODY_chore-constitution-1.0.1.md new file mode 100644 index 0000000..4d1ec10 --- /dev/null +++ b/PR_BODY_chore-constitution-1.0.1.md @@ -0,0 +1,21 @@ +Title: docs: constitution v1.0.1 — require PSScriptAnalyzer & clarify PowerShell best practices + +Body: +Clarify Code Quality & Style to explicitly require PSScriptAnalyzer checks and adherence to Microsoft +PowerShell scripting best practices. + +- Bump constitution version 1.0.0 → 1.0.1 (patch: clarifications). +- Update Sync Impact Report and Last Amended date (2025-10-02). +- Confirmed related specify templates align with the new gating rules. + +Suggested follow-ups: +- Ensure CI installs and runs PSScriptAnalyzer (add to `.github/workflows/powershell-ci.yml` if missing). +- Optionally add a PSScriptAnalyzer baseline ruleset and wire into CI. + +Files changed: +- .specify/memory/constitution.md + +PR checklist: +- [ ] CI passes (Pester + PSScriptAnalyzer) +- [ ] Reviewers: at least two maintainers for non-breaking updates +- [ ] If adding PSScriptAnalyzer baseline, include ruleset path in PR diff --git a/PR_BODY_feat-paramsets-install-speckit.md b/PR_BODY_feat-paramsets-install-speckit.md new file mode 100644 index 0000000..7e42cb0 --- /dev/null +++ b/PR_BODY_feat-paramsets-install-speckit.md @@ -0,0 +1,40 @@ +Title: feat(paramsets): add Interactive and Noninteractive ParameterSets to Install-SpecKitTemplate + +Summary + +This PR implements the design and planning artifacts for adding two ParameterSets to `tools/Install-SpecKitTemplate.ps1`: +- `Interactive` parameter set: prompts for Agent, Shell, Version, Path, and Force when run in a TTY. +- `Noninteractive` parameter set: accepts all parameters explicitly for CI usage. + +What changed (files added/updated) + +- Added feature spec and artifacts under `specs/002-parameter-sets/`: + - `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `tasks.md` +- Added feature copies under `specs/feat/paramsets-install-speckit/` for plan integration. +- Updated `tools/Install-SpecKitTemplate.ps1` (parameter-set plumbing and local edits present in branch). + +Behavior & Acceptance + +- Interactive runs will prompt only when targets exist or parameters are missing; SaveZip/Retry use defaults unless supplied. +- Running `-Interactive` in a non-TTY environment fails with exit code 2. +- Parameter-set validation is strict; incompatible parameter combinations fail with exit code 3. +- Overwrite confirmation uses a single prompt offering Yes/YesToAll/No/NoToAll; No aborts with exit code 3. + +Testing + +- This PR includes tasks and test plans under `specs/*` but does not yet add the final Pester test files. The next steps in tasks.md cover adding Pester tests and implementing code to satisfy them. + +Notes + +- The `gh` CLI is not available in the execution environment; opening the PR via the GitHub web UI is recommended. Use the auto-generated URL below or paste this body into the PR form. + +Open PR URL + +https://github.com/johnmbaughman/PSSpecKit/pull/new/feat/paramsets-install-speckit + +Reviewer checklist + +- [ ] Confirm spec and research artifacts align with implementation direction +- [ ] Review `tools/Install-SpecKitTemplate.ps1` parameter-set changes and validate no regressions +- [ ] Run the Pester tests once T002/T003 are implemented + diff --git a/PSSpecKit/PSSpecKit.psd1 b/PSSpecKit/PSSpecKit.psd1 new file mode 100644 index 0000000..e282b0a --- /dev/null +++ b/PSSpecKit/PSSpecKit.psd1 @@ -0,0 +1,133 @@ +# +# Module manifest for module 'PSSpecKit' +# +# Generated by: PSSpecKit Contributors +# +# Generated on: 10/02/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'PSSpecKit.psm1' + +# Version number of this module. +ModuleVersion = '1.0.0' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = 'e99b9261-d8cf-4f06-9a6d-983a8f298378' + +# Author of this module +Author = 'PSSpecKit Contributors' + +# Company or vendor of this module +CompanyName = 'Unknown' + +# Copyright statement for this module +Copyright = '(c) PSSpecKit Contributors. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Tools for downloading and installing GitHub Spec Kit templates' + +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '7.0' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = 'Install-SpecKitTemplate', 'Test-ZipArchive', 'Expand-SafeArchive', + 'Find-ReleaseAsset', 'Get-LatestRelease', 'Save-ReleaseAsset' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = @() + +# Variables to export from this module +# VariablesToExport = @() + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @() + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = 'spec-kit', 'templates', 'github' + + # A URL to the license for this module. + LicenseUri = 'https://github.com/johnmbaughman/PSSpecKit/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/johnmbaughman/PSSpecKit' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/PSSpecKit/PSSpecKit.psm1 b/PSSpecKit/PSSpecKit.psm1 new file mode 100644 index 0000000..1c23a38 --- /dev/null +++ b/PSSpecKit/PSSpecKit.psm1 @@ -0,0 +1,31 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Import private functions +$PrivateDir = Join-Path $PSScriptRoot 'Private' +$Private = @(Get-ChildItem -Path "$PrivateDir\*.ps1" -ErrorAction SilentlyContinue) +foreach ($import in $Private) { + try { + . $import.FullName + } catch { + Write-Error -Message "Failed to import function $($import.FullName): $_" + } +} + +# Import public functions +$PublicDir = Join-Path $PSScriptRoot 'Public' +$Public = @(Get-ChildItem -Path "$PublicDir\*.ps1" -ErrorAction SilentlyContinue) +foreach ($import in $Public) { + try { + . $import.FullName + } catch { + Write-Error -Message "Failed to import function $($import.FullName): $_" + } +} + +# Export public functions and private functions for testing +# Private functions are marked as internal and should not be used directly by end users +$AllFunctions = @($Public.BaseName) + @($Private.BaseName) +if ($AllFunctions) { + Export-ModuleMember -Function $AllFunctions +} diff --git a/PSSpecKit/Private/Expand-SafeArchive.ps1 b/PSSpecKit/Private/Expand-SafeArchive.ps1 new file mode 100644 index 0000000..a298d3b --- /dev/null +++ b/PSSpecKit/Private/Expand-SafeArchive.ps1 @@ -0,0 +1,36 @@ +function Expand-SafeArchive { + param([string]$ZipPath, [string]$TargetPath, [switch]$Force) + # Extract into a temporary extraction directory located next to the zip when possible. + # This keeps the downloaded zip in the parent work directory and allows us to remove only the extraction temp. + $zipParent = Split-Path -Path $ZipPath -Parent + if (-not $zipParent) { $zipParent = [System.IO.Path]::GetTempPath() } + $tempExtract = Join-Path -Path $zipParent -ChildPath ([System.Guid]::NewGuid().ToString()) + New-Item -Path $tempExtract -ItemType Directory | Out-Null + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $tempExtract) + # Move files from temp to target + Get-ChildItem -Path $tempExtract -Recurse | ForEach-Object { + $rel = $_.FullName.Substring($tempExtract.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar) + $dest = Join-Path $TargetPath $rel + if ($_.PSIsContainer) { + if (-not (Test-Path $dest)) { New-Item -Path $dest -ItemType Directory | Out-Null } + } else { + $destDir = Split-Path -Path $dest -Parent + if (-not (Test-Path $destDir)) { New-Item -Path $destDir -ItemType Directory | Out-Null } + if ((Test-Path $dest) -and (-not $Force)) { + Write-Warn "Skipping existing file: $dest" + } else { + Move-Item -Path $_.FullName -Destination $dest -Force:$true + } + } + } + return $true + } catch { + Write-Err "Extraction failed: $_" + return $false + } finally { + # Remove only the extraction temp directory. Do NOT remove the zip or its parent work directory. + if (Test-Path $tempExtract) { Remove-Item -Path $tempExtract -Recurse -Force } + } +} diff --git a/PSSpecKit/Private/Find-ReleaseAsset.ps1 b/PSSpecKit/Private/Find-ReleaseAsset.ps1 new file mode 100644 index 0000000..05cb54e --- /dev/null +++ b/PSSpecKit/Private/Find-ReleaseAsset.ps1 @@ -0,0 +1,15 @@ +function Find-ReleaseAsset { + param( + $Release, + [string]$Agent, + [string]$Shell + ) + $pattern = "spec-kit-template-{0}-{1}-v" -f ($Agent -replace '[^a-zA-Z0-9_-]',''), $Shell + # Try find asset containing pattern + foreach ($asset in $Release.assets) { + if ($asset.name -like "*{0}*.zip" -f $pattern) { + return $asset + } + } + return $null +} diff --git a/PSSpecKit/Private/Get-GitHubApiHeader.ps1 b/PSSpecKit/Private/Get-GitHubApiHeader.ps1 new file mode 100644 index 0000000..f0d617b --- /dev/null +++ b/PSSpecKit/Private/Get-GitHubApiHeader.ps1 @@ -0,0 +1,9 @@ +function Get-GitHubApiHeader { + [CmdletBinding()] + [OutputType([hashtable])] + param() + $headers = @{} + if ($env:GITHUB_TOKEN) { $headers['Authorization'] = "token $($env:GITHUB_TOKEN)" } + $headers['User-Agent'] = 'spec-kit-downloader' + return $headers +} diff --git a/PSSpecKit/Private/Get-LatestRelease.ps1 b/PSSpecKit/Private/Get-LatestRelease.ps1 new file mode 100644 index 0000000..50e686f --- /dev/null +++ b/PSSpecKit/Private/Get-LatestRelease.ps1 @@ -0,0 +1,14 @@ +function Get-LatestRelease { + param([string]$Owner = 'github', [string]$Repo = 'spec-kit') + $url = "https://api.github.com/repos/$Owner/$Repo/releases" + $headers = Get-GitHubApiHeader + $releases = Invoke-WithRetry -ScriptBlock { Invoke-RestMethod -Uri $url -Headers $headers -UseBasicParsing } -Retries $Retry + if (-not $releases) { throw 'No releases found' } + # Sort by semantic version if possible, fallback to published_at + try { + $sorted = $releases | Sort-Object { [Version]($_.tag_name.TrimStart('v')) } -Descending + } catch { + $sorted = $releases | Sort-Object published_at -Descending + } + return $sorted[0] +} diff --git a/PSSpecKit/Private/Invoke-WithRetry.ps1 b/PSSpecKit/Private/Invoke-WithRetry.ps1 new file mode 100644 index 0000000..0bf0626 --- /dev/null +++ b/PSSpecKit/Private/Invoke-WithRetry.ps1 @@ -0,0 +1,18 @@ +function Invoke-WithRetry { + param( + [scriptblock]$ScriptBlock, + [int]$Retries = 3 + ) + $attempt = 0 + while ($true) { + try { + return & $ScriptBlock + } catch { + $attempt++ + if ($attempt -ge $Retries) { throw } + $delay = [math]::Pow(2, $attempt) + Write-Warn "Attempt $attempt failed. Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } +} diff --git a/PSSpecKit/Private/Save-ReleaseAsset.ps1 b/PSSpecKit/Private/Save-ReleaseAsset.ps1 new file mode 100644 index 0000000..c24c232 --- /dev/null +++ b/PSSpecKit/Private/Save-ReleaseAsset.ps1 @@ -0,0 +1,21 @@ +function Save-ReleaseAsset { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] $Asset, + [string]$OutPath + ) + $headers = Get-GitHubApiHeader + if (-not $OutPath) { + # Create a dedicated temp work directory for this download and keep the zip there + $workDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ([System.Guid]::NewGuid().ToString()) + New-Item -Path $workDir -ItemType Directory -Force | Out-Null + $OutPath = Join-Path -Path $workDir -ChildPath $Asset.name + } + $url = $Asset.browser_download_url + Write-Info "Downloading $($Asset.name) from $url to $OutPath" + # Ensure the parent directory exists when OutPath is provided + $parent = Split-Path -Path $OutPath -Parent + if ($parent -and -not (Test-Path $parent)) { New-Item -Path $parent -ItemType Directory | Out-Null } + Invoke-WithRetry -ScriptBlock { Invoke-WebRequest -Uri $url -Headers $headers -OutFile $OutPath -UseBasicParsing } -Retries $Retry + return $OutPath +} diff --git a/PSSpecKit/Private/Test-ZipArchive.ps1 b/PSSpecKit/Private/Test-ZipArchive.ps1 new file mode 100644 index 0000000..244a8d6 --- /dev/null +++ b/PSSpecKit/Private/Test-ZipArchive.ps1 @@ -0,0 +1,10 @@ +function Test-ZipArchive { + param([string]$ZipPath) + try { + [System.IO.Compression.ZipFile]::OpenRead($ZipPath).Dispose() + return $true + } catch { + Write-Err "ZIP validation failed: $_" + return $false + } +} diff --git a/PSSpecKit/Private/Write-Err.ps1 b/PSSpecKit/Private/Write-Err.ps1 new file mode 100644 index 0000000..d4604d0 --- /dev/null +++ b/PSSpecKit/Private/Write-Err.ps1 @@ -0,0 +1,4 @@ +function Write-Err { + param([string]$Message) + Write-Information $Message -Tags Error +} diff --git a/PSSpecKit/Private/Write-Info.ps1 b/PSSpecKit/Private/Write-Info.ps1 new file mode 100644 index 0000000..443e697 --- /dev/null +++ b/PSSpecKit/Private/Write-Info.ps1 @@ -0,0 +1,4 @@ +function Write-Info { + param([string]$Message) + Write-Information $Message -Tags Info +} diff --git a/PSSpecKit/Private/Write-Warn.ps1 b/PSSpecKit/Private/Write-Warn.ps1 new file mode 100644 index 0000000..aec2ae0 --- /dev/null +++ b/PSSpecKit/Private/Write-Warn.ps1 @@ -0,0 +1,4 @@ +function Write-Warn { + param([string]$Message) + Write-Verbose $Message +} diff --git a/PSSpecKit/Public/Install-SpecKitTemplate.ps1 b/PSSpecKit/Public/Install-SpecKitTemplate.ps1 new file mode 100644 index 0000000..430de58 --- /dev/null +++ b/PSSpecKit/Public/Install-SpecKitTemplate.ps1 @@ -0,0 +1,151 @@ +function Install-SpecKitTemplate { + <# + .SYNOPSIS + Download and extract Spec Kit templates from the GitHub spec-kit releases. + + .DESCRIPTION + This function finds the latest spec-kit release matching an agent and shell type, + downloads the corresponding ZIP asset (pattern: spec-kit-template-[agent]-[ps|sh]-v[version].zip), + validates the ZIP, and extracts files into the target directory. + + .PARAMETER Agent + Agent name to select (optional). If omitted the function will auto-select a sensible default. + + .PARAMETER Shell + Shell type: ps (PowerShell) or sh (POSIX shell). Default: ps + + .PARAMETER Version + Release tag (e.g., v1.2.3) or 'latest' (default). When provided, the function will attempt that tag. + + .PARAMETER Retry + Number of retries for network operations (default: 3) + + .PARAMETER Force + Overwrite existing files when extracting. + + .PARAMETER Path + Target extraction directory (default: current working directory) + + .PARAMETER SaveZip + Save the downloaded ZIP file in the target directory. + + .PARAMETER Interactive + If set and multiple candidate agents exist, prompt the user. + + .EXAMPLE + Install-SpecKitTemplate + Downloads and extracts the latest spec-kit template to the current directory. + + .EXAMPLE + Install-SpecKitTemplate -Agent octo -Shell ps -Path .\templates -Force + Downloads the octo agent PowerShell template to the templates directory, overwriting existing files. + + .OUTPUTS + System.String + Returns the path where templates were extracted, or $false on failure. + #> + [CmdletBinding()] + [OutputType([string], [bool])] + param( + [string]$Agent, + [ValidateSet('ps','sh')][string]$Shell = 'ps', + [string]$Version = 'latest', + [int]$Retry = 3, + [switch]$Force, + [string]$Path = (Get-Location).Path, + [switch]$SaveZip, + [switch]$Interactive + ) + + try { + Write-Info "Starting spec-kit downloader" + + $owner = 'github' + $repo = 'spec-kit' + + # Determine release + if ($Version -ne 'latest') { + Write-Info "Looking up release $Version" + $url = "https://api.github.com/repos/$owner/$repo/releases/tags/$Version" + $headers = Get-GitHubApiHeader + $release = Invoke-WithRetry -ScriptBlock { Invoke-RestMethod -Uri $url -Headers $headers -UseBasicParsing } -Retries $Retry + } else { + Write-Info "Fetching latest release metadata" + $release = Get-LatestRelease -Owner $owner -Repo $repo + } + + if (-not $release) { throw [System.Exception] 'Release not found' } + + # Agent auto-selection + if (-not $Agent) { + # Try to infer agent from release body or assets (simplified heuristic) + $candidates = @() + foreach ($a in $release.assets) { + if ($a.name -match 'spec-kit-template-([^-]+)-') { $candidates += $matches[1] } + } + $candidates = @($candidates | Select-Object -Unique) + if ($candidates.Count -eq 0) { + if ($Interactive -and -not $env:CI) { + $inputAgent = Read-Host 'No agent candidates found. Enter agent name (or press Enter to use "default")' + if ($inputAgent) { $Agent = $inputAgent } else { $Agent = 'default' } + } else { + Write-Warn 'No agent candidates found in release; defaulting to "default"' + $Agent = 'default' + } + } elseif ($candidates.Count -eq 1) { + if ($Interactive -and -not $env:CI) { + $confirm = Read-Host "Found single candidate '$($candidates[0])'. Use this agent? (Y/n)" + if ($confirm -and $confirm -match '^[nN]') { + $alt = Read-Host 'Enter agent name' + if ($alt) { $Agent = $alt } else { $Agent = $candidates[0] } + } else { + $Agent = $candidates[0] + } + Write-Info "Agent selected: $Agent" + } else { + $Agent = $candidates[0] + Write-Info "Auto-selected agent: $Agent" + } + } else { + if ($Interactive -and -not $env:CI) { + Write-Info "Multiple agents found: $($candidates -join ', '); interactive selection enabled" + $i = 0 + foreach ($c in $candidates) { Write-Information "[$i] $c" -InformationAction Continue; $i++ } + $choice = Read-Host 'Select an agent index' + $Agent = $candidates[([int]$choice)] + } else { + # pick the first candidate as 'sensible' default + $Agent = $candidates[0] + Write-Info "Auto-selected agent (first candidate): $Agent" + } + } + } + + $asset = Find-ReleaseAsset -Release $release -Agent $Agent -Shell $Shell + if (-not $asset) { throw [System.Exception] "No matching asset found for agent=$Agent shell=$Shell" } + + # Ensure target path exists before saving if requested + if ($SaveZip -and -not (Test-Path $Path)) { New-Item -Path $Path -ItemType Directory | Out-Null } + + if ($SaveZip) { + $outZip = Save-ReleaseAsset -Asset $asset -OutPath (Join-Path -Path $Path -ChildPath $asset.name) + } else { + $outZip = Save-ReleaseAsset -Asset $asset + } + + if (-not (Test-ZipArchive -ZipPath $outZip)) { throw [System.FormatException] 'Downloaded archive failed validation' } + + if (-not (Test-Path $Path)) { New-Item -Path $Path -ItemType Directory | Out-Null } + + if (-not (Expand-SafeArchive -ZipPath $outZip -TargetPath $Path -Force:$Force)) { throw [System.IO.IOException] 'Extraction failed' } + + Write-Info "Success: templates extracted to $Path" + return $Path + } catch { + # Log error and write to error stream. Return $false so unit tests that call the function + # directly can assert on boolean failure without dealing with thrown exceptions. + Write-Err "ERROR: $_" + Write-Error -Message "Failed to install spec-kit template: $_" -ErrorAction Continue + return $false + } +} diff --git a/README.md b/README.md index e624c52..4a7e53a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,29 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -This repository contains tools for downloading and installing Spec Kit templates. +This repository contains tools for downloading and installing [GitHub Spec Kit](https://github.com/github/spec-kit) templates. -See `specs/001-create-a-powershell/quickstart.md` for examples and smoke tests. +## Installation + +The PSSpecKit module can be imported directly from the repository: + +```powershell +Import-Module ./PSSpecKit/PSSpecKit.psd1 +``` + +## Usage + +Once imported, you can use the `Install-SpecKitTemplate` cmdlet: + +```powershell +# Install the latest template to the current directory +Install-SpecKitTemplate + +# Install a specific agent template +Install-SpecKitTemplate -Agent octo -Shell ps -Path ./templates -Force +``` + +For more examples and smoke tests, see `specs/001-create-a-powershell/quickstart.md`. License ------- diff --git a/specs/feat/paramsets-install-speckit/contracts/Install-SpecKitTemplate.md b/specs/feat/paramsets-install-speckit/contracts/Install-SpecKitTemplate.md new file mode 100644 index 0000000..9420aa7 --- /dev/null +++ b/specs/feat/paramsets-install-speckit/contracts/Install-SpecKitTemplate.md @@ -0,0 +1,55 @@ +# Contract: Install-SpecKitTemplate.ps1 Parameter Sets + +## Overview +Defines the callable surface for `tools/Install-SpecKitTemplate.ps1`, including parameter sets, prompt behaviour, and exit codes for automation consumers. + +## Parameter Sets +### Noninteractive (default) +| Parameter | Type | Required | Default | Notes | +|-----------|------|----------|---------|-------| +| `Agent` | `string` | Yes | n/a | Matches release asset agent identifiers. | +| `Shell` | `string` | Yes | n/a | Must be `ps` or `sh`. | +| `Version` | `string` | Yes | n/a | Release tag (e.g., `v1.2.0`) or `latest`. | +| `Path` | `string` | No | Current working directory | Destination folder for extracted template. | +| `Force` | `switch` | No | `False` | Bypasses overwrite prompt; mutually exclusive with `-Interactive`. | +| `SaveZip` | `switch` | No | Script default | Persist downloaded archive after extraction. | +| `Retry` | `int` | No | Script default | Number of retry attempts for download operations. | + +### Interactive +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `Interactive` | `switch` | Yes | Sole trigger for interactive flow; cannot be combined with noninteractive-only parameters. | + +**Prompt sequence** (values stored in script context and echoed when defaults accepted): +1. Agent (default: previously used agent or project default). +2. Shell (`ps` default). +3. Version (`latest` default). +4. Path (current working directory default). +5. Overwrite confirmation if collisions detected (`Yes`, `No`, `Yes to all`, `No to all`). +6. Final summary confirmation (`Yes`/`No`). + +## Behavioural Guarantees +- `-Force` is rejected when supplied with `-Interactive` (binding failure → exit code 3). +- Using `-Interactive` in a non-TTY environment aborts before prompts with exit code 2 and descriptive error. +- Module functions are invoked via `Import-Module` from `$PSScriptRoot` to drive download/extract steps. +- All filesystem paths resolved relative to invocation context (no hard-coded absolutes). + +## Exit Codes +| Code | Meaning | Consumer Action | +|------|---------|-----------------| +| 0 | Install succeeded. | Continue pipeline. | +| 1 | Unexpected failure (network, module errors, etc.). | Surface logs, retry or fail build. | +| 2 | Interactive requested but no TTY available. | Re-run noninteractively or adjust environment. | +| 3 | Validation failure or user cancelled overwrite/final confirmation. | Adjust parameters, confirm overwrites, or handle aborted run. | + +## Examples +```powershell +# Interactive local run +pwsh -NoProfile -File tools/Install-SpecKitTemplate.ps1 -Interactive + +# Noninteractive CI run +pwsh -NoProfile -File tools/Install-SpecKitTemplate.ps1 ` + -Agent copilot -Shell ps -Version latest ` + -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY ` + -Force -SaveZip -Retry 3 +``` diff --git a/specs/feat/paramsets-install-speckit/data-model.md b/specs/feat/paramsets-install-speckit/data-model.md new file mode 100644 index 0000000..8133865 --- /dev/null +++ b/specs/feat/paramsets-install-speckit/data-model.md @@ -0,0 +1,37 @@ +# Data Model – ParameterSet enhancement for Install-SpecKitTemplate + +## Parameter Sets +| Name | Parameters | Mandatory | Notes | +|------|------------|-----------|-------| +| Interactive | `-Interactive` | Yes | Sole trigger for interactive experience; mutually exclusive with noninteractive arguments. | +| | `Agent` (prompted) | Prompted | Accepts existing agent values; defaults to previous selection when available. | +| | `Shell` (prompted) | Prompted | Choice constrained to `ps` / `sh`; defaults to `ps`. | +| | `Version` (prompted) | Prompted | Defaults to `latest`; accepts semantic versions. | +| | `Path` (prompted) | Prompted | Defaults to current working directory; echoed when accepted. | +| Noninteractive | `Agent` | Required | Explicit string; validated against release assets. | +| | `Shell` | Required | `ps` or `sh`. | +| | `Version` | Required | Release tag or `latest`. | +| | `Path` | Optional | Defaults to current working directory if omitted. | +| | `Force` | Optional | Enables overwrite without prompts; only valid in noninteractive set. | +| | `SaveZip` | Optional | Boolean switch (default from script settings). | +| | `Retry` | Optional | Int (default from script settings). | + +## Prompt Flow (Interactive) +1. Display header summarizing upcoming prompts. +2. Collect Agent → Shell → Version → Path (each with default shown; Enter accepts default and echoes selection). +3. Detect collisions at target path; if any, present single confirmation including `Yes`, `No`, `Yes to all`, `No to all`. +4. Present recap of chosen values and final `Proceed? (Yes/No)` confirmation. +5. Abort with exit code 3 if user declines at overwrite or final confirmation stages. + +## Exit Code Matrix +| Exit Code | Trigger | Consumer Guidance | +|-----------|---------|-------------------| +| 0 | Successful install run (interactive or noninteractive). | Downstream automation continues. | +| 1 | Unexpected/general failure (module import issues, network errors, etc.). | Surface error, retry or escalate. | +| 2 | Interactive mode requested without TTY support. | Inform caller the environment is noninteractive; rerun without `-Interactive`. | +| 3 | Parameter validation failure or user-declined overwrite/confirmation. | Adjust parameters or acknowledge abort in automation. | + +## Module Interaction +- Script imports `PSSpecKit.psm1` via `$PSScriptRoot`-relative path. +- Core installation functions (download/extract) remain in the module; script focuses on UX orchestration. +- Shared utilities (logging, asset resolution) remain within `PSSpecKit/` and are invoked post-import. diff --git a/specs/feat/paramsets-install-speckit/plan.md b/specs/feat/paramsets-install-speckit/plan.md new file mode 100644 index 0000000..ba8e233 --- /dev/null +++ b/specs/feat/paramsets-install-speckit/plan.md @@ -0,0 +1,161 @@ +# Implementation Plan: ParameterSet enhancement for Install-SpecKitTemplate + +**Branch**: `feat/paramsets-install-speckit` | **Date**: 2025-10-02 | **Spec**: [`specs/feat/paramsets-install-speckit/spec.md`](./spec.md) +**Input**: Feature specification from `specs/feat/paramsets-install-speckit/spec.md` + +## Summary +Enable `tools/Install-SpecKitTemplate.ps1` to provide a guided interactive workflow while preserving a fully parameterized, automation-friendly path. Two parameter sets (`Interactive`, `Noninteractive`) will orchestrate prompts, validation, and module-backed install logic so local users get confirmations and defaults, and CI can pass explicit arguments on the command line. + +## Technical Context +**Language/Version**: PowerShell 7.x (Core-compatible) +**Primary Dependencies**: PSSpecKit module (`PSSpecKit.psm1`), Pester v5, PSScriptAnalyzer baseline rules +**Storage**: N/A (filesystem operations scoped to user-selected paths) +**Testing**: Pester v5 suites (`tests/Install-SpecKitTemplate*.Tests.ps1`, `tests/integration/`) +**Target Platform**: PowerShell 7+ shells on Windows/macOS/Linux (TTY + non-TTY consideration) +**Project Type**: Single project (PowerShell module + supporting scripts) +**Performance Goals**: Script startup and prompt handling under 200ms cold start (Constitution default for helpers) +**Constraints**: Must pass PSScriptAnalyzer, follow Verb-Noun naming, avoid absolute paths, handle non-TTY failures explicitly +**Scale/Scope**: Single installer script with interactive prompts plus noninteractive automation usage + +## Constitution Check +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* +1. **Code Quality & Style**: Plan enforces Verb-Noun naming, module import via `$PSScriptRoot`, and keeps all filesystem paths relative. PSScriptAnalyzer checks run locally and in CI. +2. **Testing Standards**: Failing Pester tests will be authored for new parameter sets and exit-code behavior before implementation; CI workflow includes Pester + PSScriptAnalyzer gates. +3. **User Experience Consistency**: Interactive prompts mirror cmdlet UX with comment-based help, consistent parameter sets, and actionable errors for invalid combinations. +4. **Performance Requirements**: Interactive path reuses module logic and short-lived prompts, keeping cold start latency within the 200ms budget. + +**Gate Result**: PASS (no violations identified) + +## Project Structure + +### Documentation (this feature) +``` +specs/feat/paramsets-install-speckit/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +└── contracts/ + └── Install-SpecKitTemplate.md +``` + +### Source Code (repository root) +``` +PSSpecKit/ +├── Public/ +├── Private/ +├── PSSpecKit.psm1 +└── PSSpecKit.psd1 + +tools/ +├── Install-SpecKitTemplate.ps1 +├── run-pester-v5.ps1 +├── spec-kit-downloader.ps1 +└── debug-interactive-single.ps1 + +tests/ +├── Install-SpecKitTemplate.Tests.ps1 +├── Install-SpecKitTemplate.Args.Tests.ps1 +├── Install-SpecKitTemplate.Interactive.Tests.ps1 +├── Install-SpecKitTemplate.AssetSelection.Tests.ps1 +└── integration/ + +.github/workflows/ +└── (Pester + PSScriptAnalyzer CI pipeline to be added for this feature) +``` + +**Structure Decision**: Single-project PowerShell module with supporting tooling. Feature work touches `tools/Install-SpecKitTemplate.ps1`, helper functions in `PSSpecKit/`, Pester suites in `tests/`, and CI automation under `.github/workflows/`. + +## Phase 0: Outline & Research +1. **Unknowns to resolve** + - PowerShell parameter-set guidance for dual interactive/noninteractive experiences. + - Reliable non-TTY detection and exit-code conventions in PowerShell 7. + - Importing sibling modules from scripts without absolute paths. + +2. **Research tasks** + ``` + Task: "Research PowerShell parameter-set standards for dual interactive/noninteractive workflows" + Task: "Investigate reliable non-TTY detection patterns and exit-code usage in pwsh" + Task: "Document best practices for importing sibling modules from scripts using $PSScriptRoot" + ``` + +3. **Research deliverable** + - Summarize each decision with rationale and alternatives in `research.md`, linking findings to functional requirements and constitutional gates. + +**Output**: `research.md` capturing decisions and references for the three focus areas. + +## Phase 1: Design & Contracts +*Prerequisite: `research.md` complete* + +1. **Entities → `data-model.md`** + - Parameter sets (`Interactive`, `Noninteractive`) with parameter membership, mandatory flags, and validation rules. + - Prompt flow describing defaults, confirmation prompts, and summary confirmation. + - Exit-code matrix documenting triggers for codes 1, 2, and 3. + +2. **Contracts → `/contracts/Install-SpecKitTemplate.md`** + - Document invocation contracts for both parameter sets (required/optional parameters, examples). + - Capture prompt sequences, overwrite confirmation with "Yes to all/No to all", and final summary confirmation. + - Include non-TTY failure contract and expected exit codes. + +3. **Tests (failing initially)** + - Extend existing `tests/Install-SpecKitTemplate*.Tests.ps1` files with new `Describe` blocks for parameter binding, prompt defaults, non-TTY detection, and module import usage. + - Guard tests with `Pending` notes only if implementation blocking research remains (expected none after Phase 0). + +4. **User scenarios → `quickstart.md`** + - Step-by-step flows for interactive use (default acceptance, overwrite denial/approval). + - Noninteractive CI example using fully parameterized command. + - Validation checklist referencing exit codes and expected files on disk. + +5. **Agent context update** + - Run `.specify/scripts/powershell/update-agent-context.ps1 -AgentType copilot` to record new dependencies (dual parameter-set pattern, non-TTY guard, module import rule) while preserving existing context. + +**Output**: `data-model.md`, `/contracts/Install-SpecKitTemplate.md`, updated failing Pester specs, `quickstart.md`, refreshed agent context file. + +## Phase 2: Task Planning Approach +*Executed by `/tasks`; included here for traceability.* + +**Task Generation Strategy** +- Load `.specify/templates/tasks-template.md` as baseline. +- Derive tasks from Phase 1 artifacts: + - Contracts → failing test updates (mark `[P]` when independent). + - Data model → implementation/refactor tasks for script and module. + - Quickstart → documentation and manual validation tasks. +- Ensure CI workflow additions and documentation updates are explicitly captured. + +**Ordering Strategy** +- Begin with TDD: add failing Pester tests (Args, Interactive, integration). +- Follow with module import refactor and parameter-set enforcement in the script. +- Finish with CI workflow additions, comment-based help updates, and quickstart verification (docs/ops tasks `[P]`). + +**Estimated Output**: 20-25 ordered tasks in `tasks.md`, balancing parallel documentation efforts with sequential code/test work. + +## Phase 3+: Future Implementation +*Beyond `/plan`; listed for completeness* + +- **Phase 3**: `/tasks` command generates `tasks.md`. +- **Phase 4**: Implement tasks (tests first, then script/module updates, finally docs/CI). +- **Phase 5**: Validation (Pester suites, PSScriptAnalyzer, quickstart manual run, CI workflow pass). + +## Complexity Tracking +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| _None_ | n/a | n/a | + +## Progress Tracking +**Phase Status**: +- [x] Phase 0: Research complete (/plan command) +- [x] Phase 1: Design complete (/plan command) +- [x] Phase 2: Task planning complete (/plan command - describe approach only) +- [ ] Phase 3: Tasks generated (/tasks command) +- [ ] Phase 4: Implementation complete +- [ ] Phase 5: Validation passed + +**Gate Status**: +- [x] Initial Constitution Check: PASS +- [x] Post-Design Constitution Check: PASS +- [x] All NEEDS CLARIFICATION resolved +- [ ] Complexity deviations documented + +--- +*Based on Constitution v1.0.1 – see `.specify/memory/constitution.md`* diff --git a/specs/feat/paramsets-install-speckit/quickstart.md b/specs/feat/paramsets-install-speckit/quickstart.md new file mode 100644 index 0000000..d91d7c8 --- /dev/null +++ b/specs/feat/paramsets-install-speckit/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart – Install-SpecKitTemplate Parameter Sets + +## Prerequisites +- PowerShell 7.x (`pwsh`) installed and on PATH. +- Repository cloned with submodule/module dependencies restored. +- Pester v5 available (CI workflow will install if missing). + +## Interactive Workflow +1. From the repo root run: + ```powershell + pwsh -NoProfile -File tools/Install-SpecKitTemplate.ps1 -Interactive + ``` +2. Respond to prompts (press **Enter** to accept defaults; each accepted default is echoed). + - Agent → Shell (`ps`/`sh`) → Version (`latest` default) → Path (defaults to current directory). +3. If existing files are detected, choose from `Yes`, `No`, `Yes to all`, `No to all`. +4. Review the final summary confirmation and select **Yes** to proceed. +5. On completion, verify exit code 0 and inspect the target directory for generated assets. + +## Noninteractive Automation +Run the script with explicit parameters for CI or scripted scenarios: +```powershell +pwsh -NoProfile -File tools/Install-SpecKitTemplate.ps1 ` + -Agent copilot -Shell ps -Version latest ` + -Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY ` + -Force -SaveZip -Retry 3 +``` +- Exits with code 0 on success. +- Returns code 3 if validation fails or incompatible parameters are supplied. +- Returns code 2 immediately when `-Interactive` is used in a non-TTY environment. + +## Validation Checklist +- ✅ Pester suites pass: + ```powershell + pwsh -NoProfile tools/run-pester-v5.ps1 + ``` +- ✅ PSScriptAnalyzer report clean for `tools/` and `PSSpecKit/`. +- ✅ Interactive run confirms overwrites and honours defaults. +- ✅ Noninteractive run respects exit codes and writes assets to the specified path. +- ✅ CI workflow (`.github/workflows/pester-and-lint.yml`) succeeds after updates. diff --git a/specs/feat/paramsets-install-speckit/research.md b/specs/feat/paramsets-install-speckit/research.md new file mode 100644 index 0000000..04fed7a --- /dev/null +++ b/specs/feat/paramsets-install-speckit/research.md @@ -0,0 +1,22 @@ +# Research – ParameterSet enhancement for Install-SpecKitTemplate + +## Decision 1: Parameter-set design +**Decision**: Define two explicit parameter sets (`Interactive`, `Noninteractive`) using `ParameterSetName` on each parameter, ensuring `-Interactive` is the lone switch in its set while all other parameters live in the noninteractive set. +**Rationale**: Aligns with Microsoft guidance for mutually exclusive experiences—interactive flows rely on prompts, while automation requires full parameterization. This structure allows PowerShell’s binder to reject invalid combinations automatically (exit code 3 per spec). +**Alternatives Considered**: +- Single parameter set with optional `-Interactive`: rejected because it fails to prevent conflicting parameter usage and requires manual validation. +- More than two parameter sets (e.g., `ForceInteractive`): rejected as unnecessary complexity without new user stories. + +## Decision 2: Non-TTY detection & exit codes +**Decision**: Use `$Host.UI.RawUI.KeyAvailable` guard (with try/catch for hosts lacking RawUI) plus `$Host.Runspace?.OriginalHost?.UI?.SupportsVirtualTerminal` fallback to detect interactive capability. When unavailable, abort with exit code 2 as specified. +**Rationale**: Works in PowerShell 7 across consoles and CI runners, allows clear error messaging before prompts begin, and keeps handling near the parameter-set binding logic. +**Alternatives Considered**: +- Relying solely on `$PSBoundParameters.ContainsKey('Interactive')`: rejected; doesn’t ensure TTY availability. +- Using `Test-Interactive` community module: rejected to avoid external dependency. + +## Decision 3: Module import pattern +**Decision**: Load shared functions via `Import-Module (Join-Path $PSScriptRoot '..' 'PSSpecKit' 'PSSpecKit.psm1') -Force -Scope Local` before executing install logic. Resolve paths with `$PSScriptRoot` to stay relative. +**Rationale**: Upholds the constitution’s no-absolute-path rule, centralises shared logic, and keeps script updates minimal. `-Scope Local` avoids polluting caller sessions during interactive runs. +**Alternatives Considered**: +- Dot-sourcing `PSSpecKit.psm1`: rejected because dot-sourcing a module file bypasses manifest/config validation. +- Copying module functions into the script: rejected as duplication and harder to maintain. diff --git a/specs/feat/paramsets-install-speckit/spec.md b/specs/feat/paramsets-install-speckit/spec.md new file mode 100644 index 0000000..3b29683 --- /dev/null +++ b/specs/feat/paramsets-install-speckit/spec.md @@ -0,0 +1,112 @@ +# Feature: ParameterSet enhancement for Install-SpecKitTemplate + +**Feature Branch**: `feat/paramsets-install-speckit` +**Created**: 2025-10-02 +**Status**: Draft + +## Clarifications + +### Session 2025-10-02 + +- Q: Overwrite confirmation scope (required for FR-003) → A: Only prompt if files exist; single confirmation with "Yes to all / No to all" (Option C). +- Q: Behavior when `-Interactive` is used in a non-TTY environment (required for FR-005) → A: Fail immediately with a descriptive error and exit code 2 (Option A). +- Q: Prompting for `SaveZip` and `Retry` during interactive runs (affects FR-004) → A: Do not prompt; use script defaults unless parameters explicitly passed (Option B). +- Q: Parameter-set validation behavior (general) → A: Follow strict parameter-set validation rules and error the run if incompatible parameters are supplied for the selected parameter set. +- Q: Standardized exit code mapping (affects tests & automation) → A: Use exit code 1 for general errors; 2 for Interactive/TTY errors; 3 for validation/parameter-set errors (Option A). +- Q: How should `-Force` be handled across parameter sets? → A: Allow `-Force` only in the `Noninteractive` set; interactive runs rely on the overwrite prompt. +- Q: How should blank interactive prompt input be handled? → A: Accept blank input, echo the default being used, then continue. +- Q: When should "Yes to all / No to all" appear in overwrite prompts? → A: Always include these options, even if only one target is affected. +- Q: How should the installer script use the module directory? → A: Import the module at runtime and call its exported functions. +- Q: What is the default path when Enter is pressed interactively? → A: Use the current working directory as the default. + +## Execution Flow (main) + +1. Introduce two ParameterSets for `Install-SpecKitTemplate.ps1`: `Interactive` and `Noninteractive`. +2. `Interactive` parameter set uses the existing `-Interactive` switch and will cause the script to prompt + for Agent, Shell, Version, and Path values at runtime. Defaults remain as currently configured. When + the user presses Enter without input, the script accepts the default (current working directory for Path) + and echoes the value being used before proceeding. + When overriding existing files, prompt the user with a clear warning confirming overwrite. +3. `Noninteractive` parameter set accepts all parameters explicitly (Agent, Shell, Version, Path, Force, + SaveZip, Retry) and preserves existing behavior. +4. `SaveZip` and `Retry` remain as parameters available to both parameter sets. + +## Quick Guidelines + +- `Interactive` set: minimalist invocation using `-Interactive` only. Prompts must be clear and allow + sane defaults; confirmation prompts for destructive choices (overwrite) are required. When users accept + defaults by pressing Enter, echo the chosen default before continuing (Path defaults to the current + working directory). +- Prompts SHOULD only appear if one or more target files or directories already exist. When prompting + about overwrites, present a single confirmation that always includes a "Yes to all / No to all" choice + so users can accept or reject overwriting all detected targets in one response. +- A confirmation prompt MUST appear when all prompts answers are collected, summarizing the choices + and asking for final confirmation to proceed (Yes / No). +- `Noninteractive` set: full parametrization for CI and scripts; no interactive prompts. `-Force` is + exclusive to this set. + +Note: `SaveZip` and `Retry` remain configurable via parameters in both sets but will not trigger a prompt +in `-Interactive` runs — the script will use configured defaults unless the user explicitly passes those +parameters on the command line. + +## User Scenarios & Testing + +### Primary User Story +After moving the script to a modular structure, and as a developer or automation user, I want the +installer script to support an interactive workflow for local runs and a fully parameterized +non-interactive workflow for CI, so that local discovery and automation both remain ergonomic and +predictable. Script now uses a module found in the PSSpecKit module directory. + +### Acceptance Scenarios +1. Given a direct shell invocation `pwsh -NoProfile -File tools/Install-SpecKitTemplate.ps1 -Interactive`, + When no Agent/Shell/Version/Path are provided, Then the script prompts for those values and proceeds + with the provided inputs. +2. Given a CI invocation `pwsh -NoProfile -File tools/Install-SpecKitTemplate.ps1 -Agent copilot -Shell ps -Version latest -Force -SaveZip -Retry 3`, + When executed, Then the script runs non-interactively and completes without prompting. +3. Given `-Interactive` and the user accepts defaults, Then behavior matches a default noninteractive run + for the supplied defaults (SaveZip/Retry use their supplied or default values). +4. Given `-Interactive -Agent copilot -Force` and `Force` is not allowed in the `Interactive` set (example), + Then the script MUST fail parameter binding/validation and exit with a descriptive error (non-zero exit code). + +### Edge Cases +- If `-Interactive` is set but the process has no TTY (noninteractive environment), the script MUST error + with a clear message and exit code indicating interactive mode cannot run in this environment (use exit code 2). +- If `-Ice` (typo) or unknown parameter is supplied, the script MUST fail parameter binding as usual. +- If overwrite targets are detected and the user selects the negative choice (No / No to all), the script + MUST abort without modifying existing files and exit with exit code 3 (user-declined overwrite). If the user + selects the affirmative (Yes / Yes to all) the script proceeds to overwrite according to the `-Force` semantics. + +- If parameters incompatible with the selected ParameterSet are supplied (e.g., supplying interactive-only + parameters in a noninteractive run or vice versa), the script MUST fail fast during parameter binding or + validation with a descriptive error and exit code 3. + +### Exit Code Summary + +- 1 — General errors (fallback/default non-specific failures) +- 2 — Interactive / TTY related errors (e.g., `-Interactive` used in non-TTY) +- 3 — Validation / parameter-set errors (including user-declined overwrite) + +## Requirements + +### Functional Requirements +- **FR-001**: Script MUST expose two parameter sets (`Interactive`, `Noninteractive`) and associate + parameters to those sets as described, ensuring `-Force` is only available in the `Noninteractive` set. +- **FR-002**: `-Interactive` switch MUST cause the script to prompt for Agent, Shell, Version, and Path. +- **FR-003**: Force prompting in Interactive mode MUST include an explicit overwrite confirmation when files + already exist. +- **FR-004**: `SaveZip` and `Retry` MUST be available in both parameter sets and behave as currently defined. In + `Interactive` runs these values will default to the script's configured defaults and will not be prompted for + unless explicitly supplied on the command line. +- **FR-005**: Script MUST detect non-TTY environments and fail immediately with a descriptive error and exit code 2 when `-Interactive` is used. +- **FR-006**: Script MUST import the module located in the PSSpecKit module directory at runtime and call its exported functions for core functionality, ensuring modularity and maintainability. + +## Key Entities +- `Agent`: short string representing the target agent name in the release assets. +- `Shell`: either `ps` or `sh` for PowerShell or shell templates. +- `Version`: release tag or `latest`. + +## Review & Acceptance Checklist +- [ ] ParameterSets implemented and documented in script help +- [ ] Interactive prompts return values consistent with noninteractive behavior +- [ ] Pester tests added/updated for parameter set behaviors (mock Read-Host and environment) +- [ ] CI verifies noninteractive flows and runs PSScriptAnalyzer + Pester diff --git a/specs/feat/paramsets-install-speckit/tasks.md b/specs/feat/paramsets-install-speckit/tasks.md new file mode 100644 index 0000000..088000c --- /dev/null +++ b/specs/feat/paramsets-install-speckit/tasks.md @@ -0,0 +1,156 @@ +# Tasks: ParameterSet enhancement for Install-SpecKitTemplate + +**Input**: `specs/feat/paramsets-install-speckit/spec.md` +**Feature Branch**: `feat/paramsets-install-speckit` +**Feature Directory**: `C:\Personal\Files\source\repos\PSSpecKit\specs\feat\paramsets-install-speckit` +**Available Docs**: `plan.md`, `research.md`, `data-model.md`, `contracts/Install-SpecKitTemplate.md`, `quickstart.md` + +Follow these executable tasks in order. Tasks marked with **[P]** can run in parallel because they modify different files and have no dependency overlap. Each task lists required files, dependencies, and success criteria so an LLM or human can execute it without extra context. + +-## Phase 3.1 – Setup +- [X] **T001** Prepare host simulation helpers for tests + - Files: `tests/Support/HostMocks.ps1` + - Work: Add a reusable helper module exposing `New-TestHostInteractive`, `New-TestHostNonInteractive`, and prompt transcript utilities so unit/integration tests can simulate TTY/non-TTY behavior without altering global host state. + - Depends on: — + - Blocks: T002, T003, T004, T005, T006 + - Success: Tests can `.`-source the helper and obtain host objects with `RawUI` members matching PowerShell expectations. + +## Phase 3.2 – Tests First (author failing tests before implementation) +- [ ] **T002 [P]** Author contract tests for parameter sets + - Files: `tests/Install-SpecKitTemplate.Contract.Tests.ps1` + - Work: Create a Pester v5 describe block driven by `contracts/Install-SpecKitTemplate.md` that verifies mutually exclusive parameter sets, rejects `-Interactive -Force`, and asserts exit code 2 is documented for non-TTY interactive runs. + - Depends on: T001 + - Blocks: T007, T008, T009, T010, T011 + - Success: Tests fail because the script/module do not yet enforce the documented contract. + +- [ ] **T003 [P]** Extend argument-binding tests for noninteractive CLI usage + - Files: `tests/Install-SpecKitTemplate.Args.Tests.ps1` + - Work: Add Pester cases that call the script with `pwsh -File` via `Start-Process`/`&` to assert required parameters (`Agent`, `Shell`, `Version`) and verify missing values or invalid combinations emit exit code 3 with validation messaging. + - Depends on: T001 + - Blocks: T010, T011 + - Success: New tests fail, showing current implementation does not emit the expected exit codes or validation errors. + +- [ ] **T004 [P]** Expand interactive prompt flow tests + - Files: `tests/Install-SpecKitTemplate.Interactive.Tests.ps1` + - Work: Use the new host mocks to simulate accepting defaults, declining overwrite, and confirming the recap. Assert prompt sequence (Agent → Shell → Version → Path → overwrite confirmation → summary) and exit code 3 when the user declines. + - Depends on: T001 + - Blocks: T009, T010 + - Success: Tests fail because prompts are not yet orchestrated in the required order or do not echo defaults. + +- [ ] **T005 [P]** Cover exit-code routing and module import detection + - Files: `tests/Install-SpecKitTemplate.Tests.ps1` + - Work: Add cases that verify the script imports `PSSpecKit.psm1` via `$PSScriptRoot`, maps module exceptions to exit codes (0/1/3), and records `$script:LastException` for diagnostics. + - Depends on: T001 + - Blocks: T007, T008, T010, T011 + - Success: Tests fail because the script still embeds installation logic and does not import the module. + +- [ ] **T006 [P]** Add integration coverage for dual-mode runs + - Files: `tests/integration/02-parameter-sets.Tests.ps1` + - Work: Create an integration test that runs the script twice—once interactive with mocked host transcripts and once noninteractive with CLI parameters—asserting exit codes (0/2/3) and verifying artifacts written to a temporary path. + - Depends on: T001 + - Blocks: T007–T011, T015, T017 + - Success: Test fails until the script honors both parameter sets and exit codes. + +## Phase 3.3 – Core Implementation (after tests are red) +- [ ] **T007** Refactor module entrypoint for pure automation use + - Files: `PSSpecKit/Public/Install-SpecKitTemplate.ps1` + - Work: Remove inline `Read-Host` prompts, ensure the function relies solely on provided parameters, return structured errors instead of $false where appropriate, and surface metadata consumed by the script (e.g., detected collisions). + - Depends on: T002, T003, T004, T005, T006 + - Blocks: T008, T010, T011 + - Success: Module exports a prompt-free `Install-SpecKitTemplate` function suitable for script delegation, and updated unit tests still fail until the script calls it. + +- [ ] **T008** Delegate script logic to module and set up parameter-set scaffolding + - Files: `tools/Install-SpecKitTemplate.ps1` + - Work: Import `PSSpecKit.psm1` via `$PSScriptRoot`, remove duplicated download helpers, and centralize execution through the module while preserving parameter-set declarations from `data-model.md`. + - Depends on: T007 + - Blocks: T009, T010, T011, T014 + - Success: Script compiles, tests still fail on interactive/TTY expectations, and module functions are invoked for install logic. + +- [ ] **T009** Implement non-TTY guard for interactive parameter set + - Files: `tools/Install-SpecKitTemplate.ps1` + - Work: Add reusable `Test-TtyAvailable` guard (leveraging `$Host.UI.RawUI.KeyAvailable` with fallbacks). When `-Interactive` is requested without TTY support, emit descriptive messaging and exit code 2 before prompting. + - Depends on: T008 + - Blocks: T010, T017 + - Success: Interactive tests still fail on prompt order but now observe the guard when run in non-TTY simulations. + +- [ ] **T010** Build guided interactive prompt workflow + - Files: `tools/Install-SpecKitTemplate.ps1` + - Work: Implement the prompt sequence from `data-model.md` (header → Agent → Shell → Version → Path → collision confirmation → summary). Echo defaults when accepted, track decisions, and exit with code 3 when users decline overwrite or final confirmation. + - Depends on: T008, T009 + - Blocks: T011, T015, T016 + - Success: Interactive tests begin to pass once noninteractive flow still pending. + +- [ ] **T011** Finalize noninteractive execution and exit-code routing + - Files: `tools/Install-SpecKitTemplate.ps1` + - Work: Validate required parameters, pass values to the module, map module-returned errors to exit codes 0/1/3, and ensure `SaveZip`, `Retry`, and default path behaviors match `data-model.md`. + - Depends on: T008, T010 + - Blocks: T012, T014, T015, T016, T017 + - Success: Unit and integration tests transition from red to green for parameter-set behavior. + +## Phase 3.4 – Integration & Automation +- [ ] **T012** Add CI coverage for Pester + PSScriptAnalyzer on this feature + - Files: `.github/workflows/pester-and-lint.yml` + - Work: Create or update a workflow that runs `tools/run-pester-v5.ps1` and `Invoke-ScriptAnalyzer` against `tools/` and `PSSpecKit/` on pushes/PRs, capturing artifacts for exit-code assertions. + - Depends on: T011 + - Blocks: T017 + - Success: CI workflow exists, references PowerShell 7, and fails until new tests pass. + +## Phase 3.5 – Polish & Validation +- [ ] **T013 [P]** Update script comment-based help and examples + - Files: `tools/Install-SpecKitTemplate.ps1` + - Work: Refresh `.SYNOPSIS`, `.PARAMETER`, `.EXAMPLE`, and `.EXITCODES` sections to document two parameter sets, exit codes 0/1/2/3, and interactive vs noninteractive usage per contract. + - Depends on: T011 + - Blocks: — + - Success: Help text matches implemented behavior and passes script analyzer comment rules. + +- [ ] **T014 [P]** Document quickstart scenarios with new flows + - Files: `specs/feat/paramsets-install-speckit/quickstart.md` + - Work: Update quickstart to show the header prompt transcript, overwrite confirmation options, noninteractive CLI sample with exit codes, and validation checklist aligned with final behavior. + - Depends on: T011 + - Blocks: T016 + - Success: Quickstart instructions match the implemented script and integration tests. + +- [ ] **T015 [P]** Refresh feature PR template notes + - Files: `PR_BODY_feat-paramsets-install-speckit.md` + - Work: Summarize new tests, CI workflow updates, and validation steps so reviewers have ready-to-use checklist items. + - Depends on: T011 + - Blocks: — + - Success: PR body contains sections for dual parameter-set validation and references new automated checks. + +- [ ] **T016** Record manual validation results + - Files: `specs/feat/paramsets-install-speckit/quickstart.md` + - Work: After implementation, execute both flows manually (interactive defaults + CLI run) and append outcomes to the Validation Checklist with timestamps. + - Depends on: T014 + - Blocks: — + - Success: Quickstart validation checklist populated with real execution evidence. + +- [ ] **T017** Run full quality gate and capture evidence + - Files: `tests/`, `tools/run-pester-v5.ps1`, `.psscriptanalyzer.psd1`, `specs/feat/paramsets-install-speckit/quickstart.md` + - Work: Execute `tools/run-pester-v5.ps1`, run `Invoke-ScriptAnalyzer` for `tools/` + `PSSpecKit/`, and note command outputs in the quickstart or PR body. Ensure CI workflow succeeds. + - Depends on: T012, T013, T014, T015, T016 + - Blocks: — + - Success: All automated checks and manual validation steps pass and are documented. + +## Dependencies Summary +- T001 is prerequisite for all test authoring (T002–T006). +- Tests (T002–T006) must fail before starting implementation tasks T007–T011. +- Module refactor (T007) precedes any script changes (T008–T011). +- Script implementation (T008–T011) completes before CI/doc polish (T012–T017). +- Documentation and validation tasks (T014–T017) depend on working implementation and tests. + +## Parallel Execution Examples +``` +# Launch contract + argument + interactive tests together after T001: +#task run --id T002 +#task run --id T003 +#task run --id T004 +#task run --id T005 +#task run --id T006 + +# Run documentation polish concurrently once implementation is green: +#task run --id T013 +#task run --id T014 +#task run --id T015 +``` + +Generated from `.specify/templates/tasks-template.md` for feature **ParameterSet enhancement for Install-SpecKitTemplate**. diff --git a/tests/Install-SpecKitTemplate.AssetSelection.Tests.ps1 b/tests/Install-SpecKitTemplate.AssetSelection.Tests.ps1 index 7bc87ac..0bb7df4 100644 --- a/tests/Install-SpecKitTemplate.AssetSelection.Tests.ps1 +++ b/tests/Install-SpecKitTemplate.AssetSelection.Tests.ps1 @@ -1,6 +1,6 @@ Describe 'Install-SpecKitTemplate - Asset selection (T003)' { BeforeAll { - . $PSScriptRoot\..\tools\Install-SpecKitTemplate.ps1 + Import-Module "$PSScriptRoot\..\PSSpecKit\PSSpecKit.psd1" -Force # Create a fake release object $global:fakeRelease = [pscustomobject]@{ assets = @( @@ -31,7 +31,7 @@ Describe 'Install-SpecKitTemplate - Asset selection (T003)' { } Describe 'Install-SpecKitTemplate - Asset selection (T003)' { BeforeAll { - . $PSScriptRoot\..\tools\Install-SpecKitTemplate.ps1 + Import-Module "$PSScriptRoot\..\PSSpecKit\PSSpecKit.psd1" -Force # Create a fake release object $global:fakeRelease = [pscustomobject]@{ assets = @( diff --git a/tests/Install-SpecKitTemplate.Interactive.Tests.ps1 b/tests/Install-SpecKitTemplate.Interactive.Tests.ps1 index d31fa1a..e1d1165 100644 --- a/tests/Install-SpecKitTemplate.Interactive.Tests.ps1 +++ b/tests/Install-SpecKitTemplate.Interactive.Tests.ps1 @@ -1,36 +1,41 @@ Describe 'Install-SpecKitTemplate interactive flows' { BeforeAll { - # Dot-source the script under test - . $PSScriptRoot\..\tools\Install-SpecKitTemplate.ps1 + # Import the module + Import-Module "$PSScriptRoot\..\PSSpecKit\PSSpecKit.psd1" -Force } It 'prompts and accepts typed agent when no candidates found' { # Mock a release with no assets $fakeRelease = [pscustomobject]@{ assets = @() } - Mock -CommandName Get-LatestRelease -MockWith { return $fakeRelease } + Mock -CommandName Get-LatestRelease -ModuleName PSSpecKit -MockWith { return $fakeRelease } # Simulate user typing 'custom-agent' when prompted Mock -CommandName Read-Host -MockWith { return 'custom-agent' } - Install-SpecKitTemplate -Agent $null -Shell 'ps' -Version 'latest' -Retry 1 -Force:$false -Path (Join-Path $PSScriptRoot 'tmp') -SaveZip:$false -Interactive | Out-Null - # When no assets exist and user supplies custom-agent, Find-ReleaseAsset will be called with that name; the function will then throw later - $global:SPEC_KIT_DOWNLOADER_EXCEPTION | Should -Not -BeNullOrEmpty + # Clear error list before test + $Error.Clear() + + $result = Install-SpecKitTemplate -Agent $null -Shell 'ps' -Version 'latest' -Retry 1 -Force:$false -Path (Join-Path $PSScriptRoot 'tmp') -SaveZip:$false -Interactive -ErrorAction SilentlyContinue + + # When no assets exist and user supplies custom-agent, Find-ReleaseAsset will be called with that name; the function will then throw later + $result | Should -Be $false + $Error.Count | Should -BeGreaterThan 0 } It 'confirms single candidate and accepts default when user presses Enter' { # Create a fake release with one matching asset $asset = [pscustomobject]@{ name = 'spec-kit-template-myagent-ps-v1.0.0.zip'; browser_download_url = 'http://example.com/asset.zip' } $fakeRelease = [pscustomobject]@{ assets = @($asset) } - Mock -CommandName Get-LatestRelease -MockWith { return $fakeRelease } + Mock -CommandName Get-LatestRelease -ModuleName PSSpecKit -MockWith { return $fakeRelease } # Mock Read-Host to simulate pressing Enter (empty input) Mock -CommandName Read-Host -MockWith { return '' } # Also mock Save-ReleaseAsset and Expand-SafeArchive to avoid network and disk operations - Mock -CommandName Find-ReleaseAsset -MockWith { param($Release,$Agent,$Shell) return $asset } - Mock -CommandName Save-ReleaseAsset -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } - Mock -CommandName Test-ZipArchive -MockWith { return $true } - Mock -CommandName Expand-SafeArchive -MockWith { return $true } + Mock -CommandName Find-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Release,$Agent,$Shell) return $asset } + Mock -CommandName Save-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } + Mock -CommandName Test-ZipArchive -ModuleName PSSpecKit -MockWith { return $true } + Mock -CommandName Expand-SafeArchive -ModuleName PSSpecKit -MockWith { return $true } $tmp = Join-Path $PSScriptRoot 'tmp2' if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force } @@ -43,15 +48,15 @@ Describe 'Install-SpecKitTemplate interactive flows' { $a1 = [pscustomobject]@{ name = 'spec-kit-template-alpha-ps-v1.0.0.zip'; browser_download_url = 'http://example.com/a1.zip' } $a2 = [pscustomobject]@{ name = 'spec-kit-template-beta-ps-v1.0.0.zip'; browser_download_url = 'http://example.com/a2.zip' } $fakeRelease = [pscustomobject]@{ assets = @($a1,$a2) } - Mock -CommandName Get-LatestRelease -MockWith { return $fakeRelease } + Mock -CommandName Get-LatestRelease -ModuleName PSSpecKit -MockWith { return $fakeRelease } # Simulate entering index '1' to pick 'beta' Mock -CommandName Read-Host -MockWith { return '1' } - Mock -CommandName Find-ReleaseAsset -MockWith { param($Release,$Agent,$Shell) return $a2 } - Mock -CommandName Save-ReleaseAsset -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } - Mock -CommandName Test-ZipArchive -MockWith { return $true } - Mock -CommandName Expand-SafeArchive -MockWith { return $true } + Mock -CommandName Find-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Release,$Agent,$Shell) return $a2 } + Mock -CommandName Save-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } + Mock -CommandName Test-ZipArchive -ModuleName PSSpecKit -MockWith { return $true } + Mock -CommandName Expand-SafeArchive -ModuleName PSSpecKit -MockWith { return $true } $tmp = Join-Path $PSScriptRoot 'tmp3' if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force } @@ -61,37 +66,42 @@ Describe 'Install-SpecKitTemplate interactive flows' { } Describe 'Install-SpecKitTemplate interactive flows' { BeforeAll { - # Dot-source the script under test - . $PSScriptRoot\..\tools\Install-SpecKitTemplate.ps1 + # Import the module + Import-Module "$PSScriptRoot\..\PSSpecKit\PSSpecKit.psd1" -Force } It 'prompts and accepts typed agent when no candidates found' { # Mock a release with no assets $fakeRelease = [pscustomobject]@{ assets = @() } - Mock -CommandName Get-LatestRelease -MockWith { return $fakeRelease } + Mock -CommandName Get-LatestRelease -ModuleName PSSpecKit -MockWith { return $fakeRelease } # Simulate user typing 'custom-agent' when prompted Mock -CommandName Read-Host -MockWith { return 'custom-agent' } - Install-SpecKitTemplate -Agent $null -Shell 'ps' -Version 'latest' -Retry 1 -Force:$false -Path (Join-Path $PSScriptRoot 'tmp') -SaveZip:$false -Interactive | Out-Null - # When no assets exist and user supplies custom-agent, Find-ReleaseAsset will be called with that name; the function will then throw later - $global:SPEC_KIT_DOWNLOADER_EXCEPTION | Should -Not -BeNullOrEmpty + # Clear error list before test + $Error.Clear() + + $result = Install-SpecKitTemplate -Agent $null -Shell 'ps' -Version 'latest' -Retry 1 -Force:$false -Path (Join-Path $PSScriptRoot 'tmp') -SaveZip:$false -Interactive -ErrorAction SilentlyContinue + + # When no assets exist and user supplies custom-agent, Find-ReleaseAsset will be called with that name; the function will then throw later + $result | Should -Be $false + $Error.Count | Should -BeGreaterThan 0 } It 'confirms single candidate and accepts default when user presses Enter' { # Create a fake release with one matching asset $asset = [pscustomobject]@{ name = 'spec-kit-template-myagent-ps-v1.0.0.zip'; browser_download_url = 'http://example.com/asset.zip' } $fakeRelease = [pscustomobject]@{ assets = @($asset) } - Mock -CommandName Get-LatestRelease -MockWith { return $fakeRelease } + Mock -CommandName Get-LatestRelease -ModuleName PSSpecKit -MockWith { return $fakeRelease } # Mock Read-Host to simulate pressing Enter (empty input) Mock -CommandName Read-Host -MockWith { return '' } # Also mock Save-ReleaseAsset and Expand-SafeArchive to avoid network and disk operations - Mock -CommandName Find-ReleaseAsset -MockWith { param($Release,$Agent,$Shell) return $asset } - Mock -CommandName Save-ReleaseAsset -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } - Mock -CommandName Test-ZipArchive -MockWith { return $true } - Mock -CommandName Expand-SafeArchive -MockWith { return $true } + Mock -CommandName Find-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Release,$Agent,$Shell) return $asset } + Mock -CommandName Save-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } + Mock -CommandName Test-ZipArchive -ModuleName PSSpecKit -MockWith { return $true } + Mock -CommandName Expand-SafeArchive -ModuleName PSSpecKit -MockWith { return $true } $tmp = Join-Path $PSScriptRoot 'tmp2' if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force } @@ -104,15 +114,15 @@ Describe 'Install-SpecKitTemplate interactive flows' { $a1 = [pscustomobject]@{ name = 'spec-kit-template-alpha-ps-v1.0.0.zip'; browser_download_url = 'http://example.com/a1.zip' } $a2 = [pscustomobject]@{ name = 'spec-kit-template-beta-ps-v1.0.0.zip'; browser_download_url = 'http://example.com/a2.zip' } $fakeRelease = [pscustomobject]@{ assets = @($a1,$a2) } - Mock -CommandName Get-LatestRelease -MockWith { return $fakeRelease } + Mock -CommandName Get-LatestRelease -ModuleName PSSpecKit -MockWith { return $fakeRelease } # Simulate entering index '1' to pick 'beta' Mock -CommandName Read-Host -MockWith { return '1' } - Mock -CommandName Find-ReleaseAsset -MockWith { param($Release,$Agent,$Shell) return $a2 } - Mock -CommandName Save-ReleaseAsset -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } - Mock -CommandName Test-ZipArchive -MockWith { return $true } - Mock -CommandName Expand-SafeArchive -MockWith { return $true } + Mock -CommandName Find-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Release,$Agent,$Shell) return $a2 } + Mock -CommandName Save-ReleaseAsset -ModuleName PSSpecKit -MockWith { param($Asset,$OutPath) return (Join-Path ([System.IO.Path]::GetTempPath()) $Asset.name) } + Mock -CommandName Test-ZipArchive -ModuleName PSSpecKit -MockWith { return $true } + Mock -CommandName Expand-SafeArchive -ModuleName PSSpecKit -MockWith { return $true } $tmp = Join-Path $PSScriptRoot 'tmp3' if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force } diff --git a/tests/Install-SpecKitTemplate.Tests.ps1 b/tests/Install-SpecKitTemplate.Tests.ps1 index a4129fa..db17996 100644 --- a/tests/Install-SpecKitTemplate.Tests.ps1 +++ b/tests/Install-SpecKitTemplate.Tests.ps1 @@ -1,8 +1,8 @@ # Requires: PowerShell 7+ Describe 'Install-SpecKitTemplate' { - # Dot-source the script once so helper functions are available to all tests + # Import the module so functions are available to all tests BeforeAll { - . "$PSScriptRoot\..\tools\Install-SpecKitTemplate.ps1" + Import-Module "$PSScriptRoot\..\PSSpecKit\PSSpecKit.psd1" -Force # Ensure sample.zip exists for extraction tests & "$PSScriptRoot\create-sample-zip.ps1" } @@ -46,9 +46,9 @@ Describe 'Install-SpecKitTemplate' { } # Requires: PowerShell 7+ Describe 'Install-SpecKitTemplate' { - # Dot-source the script once so helper functions are available to all tests + # Import the module so functions are available to all tests BeforeAll { - . "$PSScriptRoot\..\tools\Install-SpecKitTemplate.ps1" + Import-Module "$PSScriptRoot\..\PSSpecKit\PSSpecKit.psd1" -Force # Ensure sample.zip exists for extraction tests & "$PSScriptRoot\create-sample-zip.ps1" } diff --git a/tests/Support/HostMocks.ps1 b/tests/Support/HostMocks.ps1 new file mode 100644 index 0000000..15ed966 --- /dev/null +++ b/tests/Support/HostMocks.ps1 @@ -0,0 +1,78 @@ +<# +Helper test host objects and prompt transcript utilities for Pester tests. + +Provides: + - New-TestHostInteractive + - New-TestHostNonInteractive + - Start-TestPromptTranscript + - Add-TestPromptEntry + - Get-TestPromptTranscript + - Clear-TestPromptTranscript + +#> + +function New-TestHostInteractive { + <# Creates a PSCustomObject that mimics $Host with UI.RawUI that indicates a TTY is available. #> + $rawUI = [PSCustomObject]@{ + KeyAvailable = $true + CursorSize = 1 + BackgroundColor = 'Black' + ForegroundColor = 'White' + WindowSize = [PSCustomObject]@{ Width = 120; Height = 30 } + BufferSize = [PSCustomObject]@{ Width = 120; Height = 300 } + CursorPosition = [PSCustomObject]@{ X = 0; Y = 0 } + } + + $ui = [PSCustomObject]@{ RawUI = $rawUI } + $testHostObj = [PSCustomObject]@{ Name = 'TestHost'; UI = $ui } + return $testHostObj +} + +function New-TestHostNonInteractive { + <# Creates a PSCustomObject that mimics $Host without TTY support (KeyAvailable = $false). #> + $rawUI = [PSCustomObject]@{ + KeyAvailable = $false + CursorSize = 1 + BackgroundColor = 'Black' + ForegroundColor = 'White' + WindowSize = [PSCustomObject]@{ Width = 80; Height = 25 } + BufferSize = [PSCustomObject]@{ Width = 80; Height = 200 } + CursorPosition = [PSCustomObject]@{ X = 0; Y = 0 } + } + + $ui = [PSCustomObject]@{ RawUI = $rawUI } + $testHostObj = [PSCustomObject]@{ Name = 'TestHost'; UI = $ui } + return $testHostObj +} + +# Prompt transcript utilities (simple in-memory capture for tests) +if (-not (Test-Path -LiteralPath variable:TestHostPromptTranscript -ErrorAction SilentlyContinue)) { + Set-Variable -Name TestHostPromptTranscript -Scope Script -Value @() +} + +function Start-TestPromptTranscript { + Set-Variable -Name TestHostPromptTranscript -Scope Script -Value @() +} + +function Add-TestPromptEntry { + param( + [Parameter(Mandatory=$true)] [string] $Prompt, + [Parameter(Mandatory=$true)] [string] $Response + ) + $entry = [PSCustomObject]@{ + Time = (Get-Date).ToString('o') + Prompt = $Prompt + Response = $Response + } + $script:TestHostPromptTranscript += $entry +} + +function Get-TestPromptTranscript { + return ,$script:TestHostPromptTranscript +} + +function Clear-TestPromptTranscript { + Set-Variable -Name TestHostPromptTranscript -Scope Script -Value @() +} + +# Intentionally do not call Export-ModuleMember here so the file can be dot-sourced from tests. diff --git a/tests/create-sample-zip.ps1 b/tests/create-sample-zip.ps1 new file mode 100644 index 0000000..d06db9f --- /dev/null +++ b/tests/create-sample-zip.ps1 @@ -0,0 +1,32 @@ +# Idempotent helper to create tests/sample.zip with a single hello.txt file +param() + +$scriptDir = Split-Path -Path $MyInvocation.MyCommand.Path -Parent +$zipPath = Join-Path $scriptDir 'sample.zip' +$tempDir = Join-Path $scriptDir 'sample-tmp' + +if (Test-Path $zipPath) { + # If the zip already exists and contains hello.txt, do nothing + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + $entries = [System.IO.Compression.ZipFile]::OpenRead($zipPath).Entries + if ($entries.Name -contains 'hello.txt') { return $zipPath } + } catch { + # Fall through and recreate the zip + } +} + +if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force } +New-Item -Path $tempDir -ItemType Directory | Out-Null + +$hello = Join-Path $tempDir 'hello.txt' +Set-Content -Path $hello -Value 'hello from sample.zip' + +if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::CreateFromDirectory($tempDir, $zipPath) + +Remove-Item $tempDir -Recurse -Force + +Write-Output $zipPath diff --git a/tools/Install-SpecKitTemplate.ps1 b/tools/Install-SpecKitTemplate.ps1 index 3201a85..050f3e4 100644 --- a/tools/Install-SpecKitTemplate.ps1 +++ b/tools/Install-SpecKitTemplate.ps1 @@ -36,20 +36,43 @@ pwsh tools\Install-SpecKitTemplate.ps1 pwsh tools\Install-SpecKitTemplate.ps1 -Agent octo -Shell ps -Path .\templates -Force #> +[CmdletBinding(DefaultParameterSetName='Noninteractive')] param( + # Noninteractive-only parameters + [Parameter(ParameterSetName='Noninteractive')] [string]$Agent, + + [Parameter(ParameterSetName='Noninteractive')] [ValidateSet('ps','sh')][string]$Shell = 'ps', + + [Parameter(ParameterSetName='Noninteractive')] [string]$Version = 'latest', + + # Present in both parameter sets + [Parameter(ParameterSetName='Interactive')] + [Parameter(ParameterSetName='Noninteractive')] [int]$Retry = 3, + + [Parameter(ParameterSetName='Noninteractive')] [switch]$Force, + + [Parameter(ParameterSetName='Noninteractive')] [string]$Path = (Get-Location).Path, + + [Parameter(ParameterSetName='Interactive')] + [Parameter(ParameterSetName='Noninteractive')] [switch]$SaveZip, + + [Parameter(ParameterSetName='Interactive')] [switch]$Interactive ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Script-scoped variable to capture exception details for error handling +$script:LastException = $null + # Exit code constants $EXIT_SUCCESS = 0 $EXIT_GENERIC_ERROR = 1 @@ -193,14 +216,32 @@ function Expand-SafeArchive { } function Install-SpecKitTemplate { + [CmdletBinding(DefaultParameterSetName='Noninteractive')] param( + [Parameter(ParameterSetName='Noninteractive')] [string]$Agent, + + [Parameter(ParameterSetName='Noninteractive')] [ValidateSet('ps','sh')][string]$Shell = 'ps', + + [Parameter(ParameterSetName='Noninteractive')] [string]$Version = 'latest', + + [Parameter(ParameterSetName='Interactive')] + [Parameter(ParameterSetName='Noninteractive')] [int]$Retry = 3, + + [Parameter(ParameterSetName='Noninteractive')] [switch]$Force, + + [Parameter(ParameterSetName='Noninteractive')] [string]$Path = (Get-Location).Path, + + [Parameter(ParameterSetName='Interactive')] + [Parameter(ParameterSetName='Noninteractive')] [switch]$SaveZip, + + [Parameter(ParameterSetName='Interactive')] [switch]$Interactive ) @@ -210,6 +251,38 @@ function Install-SpecKitTemplate { $owner = 'github' $repo = 'spec-kit' + # If running in interactive parameter set, prompt the user for values that + # are intentionally bypassed by the Interactive parameter set. + if ($Interactive -and -not $env:CI) { + # Prompt for Agent + $promptAgent = Read-Host 'Agent name (press Enter to use "default")' + if ($promptAgent) { $Agent = $promptAgent } elseif (-not $Agent) { $Agent = 'default' } + + # Prompt for Shell with default + $promptShell = Read-Host 'Shell (ps/sh) [ps]' + if ($promptShell -and ($promptShell -in 'ps','sh')) { $Shell = $promptShell } else { $Shell = 'ps' } + + # Prompt for Version with default + $promptVersion = Read-Host 'Version tag or "latest" [latest]' + if ($promptVersion) { $Version = $promptVersion } else { $Version = 'latest' } + + # Prompt for Path + $promptPath = Read-Host "Target extraction Path [$(Get-Location).Path]" + if ($promptPath) { $Path = $promptPath } else { $Path = (Get-Location).Path } + + # Prompt for Force override confirmation if files exist + $existing = $false + if (Test-Path $Path) { $existing = (Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue).Count -gt 0 } + if ($existing) { + $confirmForce = Read-Host 'Existing files detected in target path. Overwrite existing files? (y/N)' + if ($confirmForce -and $confirmForce -match '^[yY]') { $Force = $true } else { $Force = $false } + } else { + # No existing files; ask if they want to force future overwrites + $confirmForce = Read-Host 'Overwrite existing files if found later? (y/N)' + if ($confirmForce -and $confirmForce -match '^[yY]') { $Force = $true } else { $Force = $false } + } + } + # Determine release if ($Version -ne 'latest') { Write-Info "Looking up release $Version" @@ -223,8 +296,8 @@ function Install-SpecKitTemplate { if (-not $release) { throw [System.Exception] 'Release not found' } - # Agent auto-selection - if (-not $Agent) { + # Agent auto-selection + if (-not $Agent) { # Try to infer agent from release body or assets (simplified heuristic) $candidates = @() foreach ($a in $release.assets) { @@ -257,7 +330,7 @@ function Install-SpecKitTemplate { if ($Interactive -and -not $env:CI) { Write-Info "Multiple agents found: $($candidates -join ', '); interactive selection enabled" $i = 0 - foreach ($c in $candidates) { Write-Host "[$i] $c"; $i++ } + foreach ($c in $candidates) { Write-Information "[$i] $c" -InformationAction Continue; $i++ } $choice = Read-Host 'Select an agent index' $Agent = $candidates[([int]$choice)] } else { @@ -289,10 +362,11 @@ function Install-SpecKitTemplate { Write-Info "Success: templates extracted to $Path" return $Path } catch { - # Log and record the exception for callers. Return $false so unit tests that call the function + # Log error and store exception for callers. Return $false so unit tests that call the function # directly can assert on boolean failure without dealing with thrown exceptions. Write-Err "ERROR: $_" - $global:SPEC_KIT_DOWNLOADER_EXCEPTION = $_ + $script:LastException = $_ + Write-Error -Message "Failed to install spec-kit template: $_" -ErrorAction Continue return $false } } @@ -306,8 +380,8 @@ if ($MyInvocation.InvocationName -ne '.') { Write-Output $result exit $EXIT_SUCCESS } else { - # If the function returned $false, we may have recorded the exception in the global variable. - $ex = $global:SPEC_KIT_DOWNLOADER_EXCEPTION + # If the function returned $false, check the exception recorded in the script-scoped variable. + $ex = $script:LastException if ($ex -is [System.Net.WebException]) { Write-Err "Network error: $ex" exit $EXIT_NETWORK_ERROR diff --git a/tools/run-pester-v5.ps1 b/tools/run-pester-v5.ps1 index de147a8..6c5a379 100644 --- a/tools/run-pester-v5.ps1 +++ b/tools/run-pester-v5.ps1 @@ -4,7 +4,7 @@ param( [switch]$AutoInstall # If set, install Pester v5 automatically into CurrentUser scope when missing ) -function Ensure-PesterV5 { +function Test-PesterV5Available { try { $m = Get-Module -ListAvailable -Name Pester | Sort-Object Version -Descending | Select-Object -First 1 if ($m -and $m.Version -ge [Version]'5.0.0') { @@ -16,10 +16,10 @@ function Ensure-PesterV5 { } } -if (-not (Ensure-PesterV5)) { - Write-Host 'Pester v5 is not available in your session.' +if (-not (Test-PesterV5Available)) { + Write-Information 'Pester v5 is not available in your session.' -InformationAction Continue if ($AutoInstall) { - Write-Host 'Installing Pester v5 to CurrentUser scope...' + Write-Information 'Installing Pester v5 to CurrentUser scope...' -InformationAction Continue try { Install-Module -Name Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -AcceptLicense } catch { @@ -27,16 +27,16 @@ if (-not (Ensure-PesterV5)) { exit 1 } } else { - Write-Host "Run this to install Pester v5 for your user:" - Write-Host " pwsh -Command \"Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -AcceptLicense\"" - Write-Host 'Or re-run this helper with -AutoInstall to install automatically.' + Write-Information "Run this to install Pester v5 for your user:" -InformationAction Continue + Write-Information " pwsh -Command `"Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -AcceptLicense`"" -InformationAction Continue + Write-Information 'Or re-run this helper with -AutoInstall to install automatically.' -InformationAction Continue exit 2 } } Import-Module Pester -MinimumVersion 5.0.0 -Force -Write-Host "Loaded Pester: $((Get-Module Pester).Version)" +Write-Information "Loaded Pester: $((Get-Module Pester).Version)" -InformationAction Continue $r = Pester\Invoke-Pester -Path .\tests -PassThru -Write-Host "FailedCount=$($r.FailedCount)" +Write-Information "FailedCount=$($r.FailedCount)" -InformationAction Continue if ($r.FailedCount -gt 0) { exit 1 } else { exit 0 } diff --git a/tools/spec-kit-downloader.ps1 b/tools/spec-kit-downloader.ps1 index 10548ca..2c15748 100644 --- a/tools/spec-kit-downloader.ps1 +++ b/tools/spec-kit-downloader.ps1 @@ -50,6 +50,9 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Script-scoped variable to capture exception details for error handling +$script:LastException = $null + # Exit code constants $EXIT_SUCCESS = 0 $EXIT_GENERIC_ERROR = 1 @@ -257,7 +260,7 @@ function Install-SpecKitTemplate { if ($Interactive -and -not $env:CI) { Write-Info "Multiple agents found: $($candidates -join ', '); interactive selection enabled" $i = 0 - foreach ($c in $candidates) { Write-Host "[$i] $c"; $i++ } + foreach ($c in $candidates) { Write-Information "[$i] $c" -InformationAction Continue; $i++ } $choice = Read-Host 'Select an agent index' $Agent = $candidates[([int]$choice)] } else { @@ -289,10 +292,11 @@ function Install-SpecKitTemplate { Write-Info "Success: templates extracted to $Path" return $Path } catch { - # Log and record the exception for callers. Return $false so unit tests that call the function + # Log error and store exception for callers. Return $false so unit tests that call the function # directly can assert on boolean failure without dealing with thrown exceptions. Write-Err "ERROR: $_" - $global:SPEC_KIT_DOWNLOADER_EXCEPTION = $_ + $script:LastException = $_ + Write-Error -Message "Failed to install spec-kit template: $_" -ErrorAction Continue return $false } } @@ -306,8 +310,8 @@ if ($MyInvocation.InvocationName -ne '.') { Write-Output $result exit $EXIT_SUCCESS } else { - # If the function returned $false, we may have recorded the exception in the global variable. - $ex = $global:SPEC_KIT_DOWNLOADER_EXCEPTION + # If the function returned $false, check the exception recorded in the script-scoped variable. + $ex = $script:LastException if ($ex -is [System.Net.WebException]) { Write-Err "Network error: $ex" exit $EXIT_NETWORK_ERROR