diff --git a/.github/renovate.json b/.github/renovate.json index 9dcf4a8..954f51b 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -31,6 +31,17 @@ "matchStrings": [ "https://github\\.com/(?[\\w-]+/[\\w.-]+)/archive/(?[a-f0-9]{40})\\.tar\\.gz" ] + }, + { + "customType": "regex", + "datasourceTemplate": "nuget", + "managerFilePatterns": [ + "/^powershell-modules\\.psd1$/" + ], + "matchStrings": [ + "(?\\w[\\w.]*)\\s*=\\s*'(?[^']+)'" + ], + "registryUrlTemplate": "https://www.powershellgallery.com/api/v2/" } ], "extends": [ diff --git a/.gitignore b/.gitignore index 5d6155d..b1a8a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ # Node.js node_modules/ + +# PowerShell Gallery modules provisioned by scripts/provision-psmodules.ps1 +.psmodules/ diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..010c038 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,28 @@ +@{ + # Auto-discovered by the VS Code PowerShell extension, so the editor and the + # psscriptanalyzer hk step enforce the same rules. The step fails on any + # finding (all severities), so no Severity gate is set. Default correctness + # rules run regardless (IncludeDefaultRules only matters with CustomRulePath); + # the entries below opt into the formatting rules, which are off by default. + Rules = @{ + PSAvoidLongLines = @{ + Enable = $true + MaximumLineLength = 100 + } + PSAvoidSemicolonsAsLineTerminators = @{ Enable = $true } + PSPlaceCloseBrace = @{ + Enable = $true + NoEmptyLineBefore = $true + } + PSPlaceOpenBrace = @{ Enable = $true } + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 2 + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckParameter = $true + } + PSUseCorrectCasing = @{ Enable = $true } + } +} diff --git a/hk.pkl b/hk.pkl index 77bd872..23fd5b9 100644 --- a/hk.pkl +++ b/hk.pkl @@ -33,6 +33,18 @@ local allSteps = new Mapping { glob = List("**/*.tmpl") check = "sh scripts/lint-templates.sh {{files}}" } + + // PSScriptAnalyzer over PowerShell sources. The `**/*.ps1` glob leaves + // `.ps1.tmpl` to render-templates (rendered-output linting is deferred with + // the shell case). pwsh is aqua-managed via mise; the module is provisioned + // into .psmodules by scripts/provision-psmodules.ps1 (mise postinstall). + // Check-only: PSScriptAnalyzer's -Fix is unreliable (see lint-powershell.ps1), + // so no fix command is wired; formatting is left to the editor. + ["psscriptanalyzer"] { + glob = List("**/*.ps1") + exclude = Defaults.defaultExclude + check = "pwsh -NoProfile -File scripts/lint-powershell.ps1 {{files}}" + } } hooks = Defaults.hooksFor(allSteps) diff --git a/home/.chezmoiscripts/windows/run_onchange_after_skills-symlinks.ps1 b/home/.chezmoiscripts/windows/run_onchange_after_skills-symlinks.ps1 index 1d19e3f..0383ff8 100644 --- a/home/.chezmoiscripts/windows/run_onchange_after_skills-symlinks.ps1 +++ b/home/.chezmoiscripts/windows/run_onchange_after_skills-symlinks.ps1 @@ -1,6 +1,6 @@ # Symlink ~/.claude/skills to the canonical ~/.agents/skills directory so # Claude Code (which only reads ~/.claude/skills) sees the shared skills. -$SkillsTarget = Join-Path $env:USERPROFILE ".agents" "skills" -$Link = Join-Path $env:USERPROFILE ".claude" "skills" +$SkillsTarget = Join-Path -Path $env:USERPROFILE -ChildPath ".agents" -AdditionalChildPath "skills" +$Link = Join-Path -Path $env:USERPROFILE -ChildPath ".claude" -AdditionalChildPath "skills" if (Test-Path $Link) { Remove-Item $Link -Force } New-Item -ItemType SymbolicLink -Path $Link -Target $SkillsTarget | Out-Null diff --git a/home/dot_config/powershell/profile.d/20-prompt.ps1 b/home/dot_config/powershell/profile.d/20-prompt.ps1 index e35a9d9..0ca3b5a 100644 --- a/home/dot_config/powershell/profile.d/20-prompt.ps1 +++ b/home/dot_config/powershell/profile.d/20-prompt.ps1 @@ -1,5 +1,11 @@ # Configure OSC 7 for Starship # https://wezterm.org/shell-integration.html#osc-7-on-windows-with-powershell-with-starship +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingInvokeExpression', '', + Justification = 'starship init emits a script string to Invoke-Expression' +)] +param() + $prompt = "" function Invoke-Starship-PreCommand { $current_location = $executionContext.SessionState.Path.CurrentLocation diff --git a/home/dot_config/powershell/profile.d/30-modules.ps1 b/home/dot_config/powershell/profile.d/30-modules.ps1 index e31f504..c897e90 100644 --- a/home/dot_config/powershell/profile.d/30-modules.ps1 +++ b/home/dot_config/powershell/profile.d/30-modules.ps1 @@ -13,7 +13,7 @@ Set-PSReadLineKeyHandler ` -Description 'Autocomplete commands via fzf' ` -ScriptBlock { Invoke-FzfTabCompletion } function local:Write-AtCursor ([Parameter(ValueFromPipeline)]$Result) { - If ($Result.Length -gt 0) { + if ($Result.Length -gt 0) { [Microsoft.PowerShell.PSConsoleReadLine]::Insert($Result -join "") } [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() diff --git a/mise.lock b/mise.lock index 8836e2c..8114482 100644 --- a/mise.lock +++ b/mise.lock @@ -153,6 +153,30 @@ checksum = "sha256:82c130717a59f237c49e8eaa8a7ed75c1d8d351a5b6dedeee3196b8e1b597 url = "https://github.com/pnpm/pnpm/releases/download/v11.9.0/pnpm-win32-x64.zip" provenance = "github-attestations" +[[tools.powershell]] +version = "7.6.3" +backend = "aqua:PowerShell/PowerShell" + +[tools.powershell."platforms.linux-arm64"] +checksum = "sha256:7a14a385eca7dc5bedc1c8aa3d8b765f449ada30aabe5785a9fd331266eb062d" +url = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.3/powershell-7.6.3-linux-arm64.tar.gz" + +[tools.powershell."platforms.linux-x64"] +checksum = "sha256:856d0765d2332377f9d7a4aea76efdfde4de51446e7738dde2dfda41dba9e2a7" +url = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.3/powershell-7.6.3-linux-x64.tar.gz" + +[tools.powershell."platforms.macos-arm64"] +checksum = "sha256:f0263c2072fe7d0953781c60497a574bea99b37237f2554a59ce4bad07de8d36" +url = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.3/powershell-7.6.3-osx-arm64.tar.gz" + +[tools.powershell."platforms.macos-x64"] +checksum = "sha256:f02073a442515877aa5a8f361f55866800100c41b665cfb64883b77dbba09412" +url = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.3/powershell-7.6.3-osx-x64.tar.gz" + +[tools.powershell."platforms.windows-x64"] +checksum = "sha256:07ddb0d00b660459560ef82a9841da7705b27cd5dcca5a0d7b025a98eca29eca" +url = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.3/PowerShell-7.6.3-win-x64.zip" + [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" diff --git a/mise.toml b/mise.toml index 7fbf0db..a0e036e 100644 --- a/mise.toml +++ b/mise.toml @@ -44,5 +44,12 @@ node = "24.18.0" "npm:renovate" = "43.231.2" pkl = "0.31.1" pnpm = "11.9.0" +# Backs the hk psscriptanalyzer step (and lint-powershell.ps1) so pwsh is pinned +# and reproducible on dev machines and CI rather than relying on an ambient one. +# The per-tool postinstall provisions the pinned PowerShell Gallery modules (see +# powershell-modules.psd1) once pwsh is installed; the script is idempotent, so +# it re-runs cheaply on reinstall. Bumping a module version in the manifest +# needs a `mise install --force powershell` (or a manual run) to re-provision. +powershell = { version = "7.6.3", postinstall = "pwsh -NoProfile -File scripts/provision-psmodules.ps1" } shellcheck = "0.11.0" shfmt = "3.13.1" diff --git a/powershell-modules.psd1 b/powershell-modules.psd1 new file mode 100644 index 0000000..1cb6743 --- /dev/null +++ b/powershell-modules.psd1 @@ -0,0 +1,12 @@ +@{ + # PowerShell Gallery modules the dev toolchain provisions into a repo-local, + # gitignored .psmodules via scripts/provision-psmodules.ps1 (run from the mise + # postinstall hook). Add a module by listing its pinned version here; the + # provisioner is generic and installs whatever this manifest declares. + # + # Renovate keeps these current via the nuget datasource pointed at the + # PowerShell Gallery feed (a custom manager in .github/renovate.json), so a + # bump lands like any other dependency PR. Keep the mapping alphabetical to + # reduce merge conflicts. + PSScriptAnalyzer = '1.24.0' +} diff --git a/scripts/lint-powershell.ps1 b/scripts/lint-powershell.ps1 new file mode 100644 index 0000000..8531895 --- /dev/null +++ b/scripts/lint-powershell.ps1 @@ -0,0 +1,46 @@ +#!/usr/bin/env pwsh +# Lint PowerShell sources with PSScriptAnalyzer, mirroring +# scripts/lint-templates.sh: hk passes the changed files as positional args and +# this wraps the module so the hk step stays a one-liner. Rules live in +# PSScriptAnalyzerSettings.psd1 at the repo root (also honored by the VS Code +# PowerShell extension). +# +# Check-only: PSScriptAnalyzer's -Fix rewrites files in place and throws on some +# valid inputs (e.g. NullReferenceException on scripts with backtick line +# continuations), so autofix is left to the editor, which applies the same psd1. +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Path = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($Path.Count -eq 0) { exit 0 } + +# Prefer the repo-local, pinned module provisioned by provision-psmodules.ps1; +# fall back to whatever PSScriptAnalyzer is already on PSModulePath. +$root = Join-Path $PSScriptRoot '..' +$repoModules = Join-Path $root '.psmodules' +if (Test-Path $repoModules) { + $env:PSModulePath = $repoModules + [IO.Path]::PathSeparator + $env:PSModulePath +} + +$settings = Join-Path $root 'PSScriptAnalyzerSettings.psd1' + +# Invoke-ScriptAnalyzer -Path is single-valued, so analyze each file in turn. +$findings = foreach ($file in $Path) { + Invoke-ScriptAnalyzer -Path $file -Settings $settings +} + +if ($findings) { + $report = $findings | + Format-Table -AutoSize -Wrap -Property Severity, RuleName, + @{ Label = 'Location'; Expression = { '{0}:{1}' -f $_.ScriptPath, $_.Line } }, + Message | + Out-String + [Console]::Error.WriteLine($report) + exit 1 +} +exit 0 diff --git a/scripts/provision-psmodules.ps1 b/scripts/provision-psmodules.ps1 new file mode 100644 index 0000000..19cf66e --- /dev/null +++ b/scripts/provision-psmodules.ps1 @@ -0,0 +1,33 @@ +#!/usr/bin/env pwsh +# Provision pinned PowerShell Gallery modules into a repo-local, gitignored +# .psmodules so PowerShell-backed hk steps (e.g. psscriptanalyzer) are +# reproducible across dev machines and CI. mise has no PowerShell Gallery +# backend (and none is tracked upstream), so this bootstrap is the durable way +# to pin PS modules alongside the aqua-managed pwsh in mise.toml. +# +# Generic and data-driven: the module -> version map lives in +# powershell-modules.psd1 at the repo root. Idempotent -- a module already +# present at its pinned version is skipped, so re-running the postinstall hook +# is cheap. +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = Join-Path $PSScriptRoot '..' +$manifest = Join-Path $root 'powershell-modules.psd1' +$dest = Join-Path $root '.psmodules' + +$modules = Import-PowerShellDataFile -Path $manifest +if ($modules.Count -eq 0) { exit 0 } + +New-Item -ItemType Directory -Force -Path $dest | Out-Null + +foreach ($name in $modules.Keys) { + $version = $modules[$name] + if (Test-Path (Join-Path $dest "$name/$version")) { continue } + + [Console]::Error.WriteLine("Provisioning $name $version into .psmodules") + Save-Module -Name $name -RequiredVersion $version -Repository PSGallery -Path $dest +}