PowerShell 技能连载 - VS Code 扩展开发

适用于 PowerShell 7.0 及以上版本

VS Code 的扩展生态极其丰富,但当现有扩展无法满足需求时,我们就需要自己动手开发。虽然 VS Code 扩展通常用 TypeScript 编写,但 PowerShell 在扩展开发的工程化管理中扮演着不可或缺的角色——从脚手架生成、依赖安装、构建打包到发布上线,整个生命周期都可以用 PowerShell 自动化。特别是在团队协作中,统一的构建和发布脚本能大幅降低出错概率。

本文将介绍如何利用 PowerShell 自动化 VS Code 扩展开发的完整流程,包括环境准备、项目初始化、构建打包和版本发布,帮助你在扩展开发中事半功倍。

环境准备与检查

开发 VS Code 扩展需要 Node.js、npm 以及 Yeoman 生成器等工具。在正式开始之前,先用 PowerShell 脚本检查当前环境是否满足要求,缺少的依赖自动安装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 检查 VS Code 扩展开发环境
function Test-VSCodeDevEnvironment {
$requirements = @(
@{ Name = "Node.js"; Command = "node"; VersionArg = "--version" }
@{ Name = "npm"; Command = "npm"; VersionArg = "--version" }
@{ Name = "VS Code CLI"; Command = "code"; VersionArg = "--version" }
@{ Name = "yo (Yeoman)"; Command = "yo"; VersionArg = "--version" }
@{ Name = "vsce (VS Code Extension Manager)"; Command = "vsce"; VersionArg = "--version" }
)

$results = @()
foreach ($req in $requirements) {
$cmd = Get-Command $req.Command -ErrorAction SilentlyContinue
if ($cmd) {
$version = & $req.Command $req.VersionArg 2>$null | Select-Object -First 1
$status = "OK"
}
else {
$version = "未安装"
$status = "MISSING"
}
$results += [PSCustomObject]@{
Component = $req.Name
Status = $status
Version = $version
}
}

$results | Format-Table -AutoSize
$missing = $results | Where-Object { $_.Status -eq "MISSING" }
if ($missing) {
Write-Host "`n缺少以下组件:" -ForegroundColor Yellow
foreach ($m in $missing) {
Write-Host " - $($m.Component)" -ForegroundColor Red
}
}
else {
Write-Host "`n所有组件已就绪!" -ForegroundColor Green
}
}

Test-VSCodeDevEnvironment

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
Component                       Status Version
--------- ------ -------
Node.js OK v20.11.0
npm OK 10.2.4
VS Code CLI OK 1.87.0
yo (Yeoman) MISSING 未安装
vsce (VS Code Extension Manager) MISSING 未安装

缺少以下组件:
- yo (Yeoman)
- vsce (VS Code Extension Manager)

当检测到缺少组件时,可以一键安装。Yeoman 和 generator-code 用于生成扩展脚手架,vsce 用于打包和发布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 一键安装缺失的开发依赖
function Install-VSCodeDevDependencies {
param(
[switch]$Force
)

$packages = @("yo", "generator-code", "@vscode/vsce")

foreach ($pkg in $packages) {
$installed = npm list -g $pkg 2>$null | Select-String $pkg
if ($Force -or -not $installed) {
Write-Host "正在安装 $pkg ..." -ForegroundColor Cyan
npm install -g $pkg
}
else {
Write-Host "$pkg 已安装,跳过" -ForegroundColor Gray
}
}

Write-Host "`n依赖安装完成" -ForegroundColor Green
}

Install-VSCodeDevDependencies

执行结果示例:

1
2
3
4
5
6
7
8
正在安装 yo ...
added 1 package in 3s
正在安装 generator-code ...
added 1 package in 2s
正在安装 @vscode/vsce ...
added 1 package in 4s

依赖安装完成

项目初始化与脚手架生成

Yeoman 的 VS Code 扩展生成器需要交互式输入。在自动化场景中,我们可以通过 PowerShell 预设所有参数,然后以非交互方式生成项目,实现”一键创建扩展项目”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# 自动化生成 VS Code 扩展脚手架
function New-VSCodeExtension {
param(
[Parameter(Mandatory)]
[string]$Name,

[string]$DisplayName = $Name,
[string]$Description = "A VS Code extension",
[ValidateSet("typescript", "javascript")]
[string]$Language = "typescript",
[string]$OutputPath = (Get-Location).Path
)

$extensionDir = Join-Path $OutputPath $Name
if (Test-Path $extensionDir) {
Write-Host "目录已存在: $extensionDir" -ForegroundColor Red
return
}

# 创建临时目录用于 yo 生成
$tempDir = Join-Path $env:TEMP "vscode-ext-$Name-$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
Push-Location $tempDir

try {
# 构造 yo 的非交互式输入
# yo code 参数顺序: name, display name, description, git init, pkg manager, language
$inputs = @(
$Name,
$DisplayName,
$Description,
"Y", # 初始化 Git 仓库
"npm", # 包管理器
$Language
)

$inputString = $inputs -join "`n"
Write-Host "正在生成扩展项目: $Name ..." -ForegroundColor Cyan

$proc = Start-Process -FilePath "yo" `
-ArgumentList "code" `
-NoNewWindow -Wait -PassThru `
-RedirectStandardInput (Write-Output $inputString | Out-File -FilePath "$tempDir\input.txt" -Encoding utf8; "$tempDir\input.txt")

# 将生成的项目移动到目标位置
$generated = Get-ChildItem $tempDir -Directory | Select-Object -First 1
if ($generated) {
Move-Item $generated.FullName $extensionDir
Write-Host "扩展项目已创建: $extensionDir" -ForegroundColor Green

# 安装依赖
Push-Location $extensionDir
npm install
Pop-Location

Write-Host "`n项目结构:" -ForegroundColor Cyan
Get-ChildItem $extensionDir -Depth 1 | ForEach-Object {
$indent = if ($_.PSIsContainer) { "[DIR] " } else { "[FILE] " }
Write-Host " $indent $($_.Name)"
}
}
}
finally {
Pop-Location
if (Test-Path $tempDir) {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}

New-VSCodeExtension -Name "my-hello-extension" `
-DisplayName "My Hello Extension" `
-Description "A simple hello world extension"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
正在生成扩展项目: my-hello-extension ...

added 156 packages in 8s

扩展项目已创建: /home/user/my-hello-extension

项目结构:
[DIR] src
[FILE] .gitignore
[FILE] .vscodeignore
[FILE] CHANGELOG.md
[FILE] LICENSE
[FILE] README.md
[FILE] package.json
[FILE] tsconfig.json
[FILE] vsc-extension-quickstart.md

生成的 package.json 是扩展的核心配置文件。我们可以用 PowerShell 读取并修改其中的关键字段,比如自动更新版本号或添加贡献点。

构建与打包自动化

VS Code 扩展在开发和发布阶段有不同的构建需求。开发时需要编译 TypeScript 并在本地调试,发布时需要用 vsce 打包为 .vsix 文件。下面的脚本封装了完整的构建流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# VS Code 扩展构建与打包
function Build-VSCodeExtension {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

[ValidateSet("dev", "prod")]
[string]$Mode = "dev",

[string]$OutputPath
)

if (-not (Test-Path $ProjectPath)) {
Write-Host "项目路径不存在: $ProjectPath" -ForegroundColor Red
return
}

Push-Location $ProjectPath
try {
# 读取 package.json 获取扩展信息
$pkg = Get-Content "package.json" -Raw | ConvertFrom-Json
Write-Host "扩展: $($pkg.displayName) v$($pkg.version)" -ForegroundColor Cyan
Write-Host "构建模式: $Mode`n"

# 清理旧的构建产物
if (Test-Path "out") {
Remove-Item "out" -Recurse -Force
}

# 编译 TypeScript
Write-Host "[1/3] 编译 TypeScript..." -ForegroundColor Yellow
npm run compile 2>&1 | ForEach-Object { Write-Host " $_" }
if ($LASTEXITCODE -ne 0) {
Write-Host "编译失败!" -ForegroundColor Red
return
}

# 运行测试(仅 dev 模式)
if ($Mode -eq "dev") {
Write-Host "[2/3] 运行测试..." -ForegroundColor Yellow
npm test 2>&1 | ForEach-Object { Write-Host " $_" }
}
else {
Write-Host "[2/3] 跳过测试(prod 模式)" -ForegroundColor Gray
}

# 打包为 .vsix(仅 prod 模式)
if ($Mode -eq "prod") {
Write-Host "[3/3] 打包 .vsix 文件..." -ForegroundColor Yellow
$vsixOutput = vsce package 2>&1
$vsixOutput | ForEach-Object { Write-Host " $_" }

$vsixFile = Get-ChildItem -Path "." -Filter "*.vsix" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1

if ($vsixFile -and $OutputPath) {
$dest = Join-Path $OutputPath $vsixFile.Name
Move-Item $vsixFile.FullName $dest -Force
Write-Host "`n已输出: $dest" -ForegroundColor Green
}
elseif ($vsixFile) {
Write-Host "`n已生成: $($vsixFile.FullName)" -ForegroundColor Green
Write-Host "文件大小: $([math]::Round($vsixFile.Length / 1KB, 2)) KB" -ForegroundColor Gray
}
}
else {
Write-Host "[3/3] 开发构建完成,跳过打包" -ForegroundColor Gray
}

Write-Host "`n构建成功!" -ForegroundColor Green
}
finally {
Pop-Location
}
}

Build-VSCodeExtension -ProjectPath "./my-hello-extension" -Mode prod

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
扩展: My Hello Extension v0.0.1
构建模式: prod

[1/3] 编译 TypeScript...
src/extension.ts -> out/extension.js
[2/3] 跳过测试(prod 模式)
[3/3] 打包 .vsix 文件...
DONE Packaged: my-hello-extension-0.0.1.vsix (3.14 KB)

已生成: /home/user/my-hello-extension/my-hello-extension-0.0.1.vsix
文件大小: 3.14 KB

构建成功!

版本管理与发布

扩展的版本管理需要遵循语义化版本规范。以下脚本实现了版本号自动递增、Git 打标签、打包并发布到 VS Code Marketplace 的完整流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# VS Code 扩展版本管理与发布
function Publish-VSCodeExtension {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

[Parameter(Mandatory)]
[ValidateSet("patch", "minor", "major")]
[string]$VersionBump,

[string]$PersonalAccessToken,

[switch]$DryRun
)

Push-Location $ProjectPath
try {
$pkg = Get-Content "package.json" -Raw | ConvertFrom-Json
$oldVersion = $pkg.version
$parts = $oldVersion.Split(".")
$major = [int]$parts[0]
$minor = [int]$parts[1]
$patch = [int]$parts[2]

switch ($VersionBump) {
"patch" { $patch++ }
"minor" { $minor++; $patch = 0 }
"major" { $major++; $minor = 0; $patch = 0 }
}

$newVersion = "$major.$minor.$patch"
Write-Host "版本变更: $oldVersion -> $newVersion" -ForegroundColor Cyan

if ($DryRun) {
Write-Host "[DryRun] 仅显示操作,不执行" -ForegroundColor Yellow
Write-Host " - 更新 package.json 版本号"
Write-Host " - Git commit & tag v$newVersion"
Write-Host " - vsce publish"
return
}

# 更新 package.json 中的版本号
$pkg.version = $newVersion
$pkg | ConvertTo-Json -Depth 10 | Set-Content "package.json" -Encoding UTF8
Write-Host "[1/4] 已更新 package.json" -ForegroundColor Green

# 更新 CHANGELOG
$changelogPath = "CHANGELOG.md"
$date = Get-Date -Format "yyyy-MM-dd"
$newEntry = "## [$newVersion] - $date`n`n- Updated extension`n"
if (Test-Path $changelogPath) {
$content = Get-Content $changelogPath -Raw
$content = $content -replace "(## \[Unreleased\]|^#)", "`$0`n$newEntry"
Set-Content $changelogPath $content -Encoding UTF8
}
Write-Host "[2/4] 已更新 CHANGELOG.md" -ForegroundColor Green

# Git 提交和打标签
git add package.json CHANGELOG.md
git commit -m "chore: bump version to $newVersion"
git tag "v$newVersion"
Write-Host "[3/4] Git commit & tag 完成" -ForegroundColor Green

# 发布到 Marketplace
if ($PersonalAccessToken) {
$env:VSCE_PAT = $PersonalAccessToken
}
vsce publish $newVersion 2>&1 | ForEach-Object { Write-Host " $_" }
Write-Host "[4/4] 发布完成!" -ForegroundColor Green

Write-Host "`n扩展 $newVersion 已发布到 VS Code Marketplace" -ForegroundColor Green
}
finally {
Pop-Location
}
}

# 预演模式:查看操作但不执行
Publish-VSCodeExtension -ProjectPath "./my-hello-extension" `
-VersionBump patch -DryRun

# 正式发布(需要提供 PAT)
# Publish-VSCodeExtension -ProjectPath "./my-hello-extension" `
# -VersionBump patch `
# -PersonalAccessToken "your-pat-here"

执行结果示例:

1
2
3
4
5
版本变更: 0.0.1 -> 0.0.2
[DryRun] 仅显示操作,不执行
- 更新 package.json 版本号
- Git commit & tag v0.0.2
- vsce publish

去掉 -DryRun 并提供 Personal Access Token 后,脚本将自动完成版本递增、Git 打标签和 Marketplace 发布。

注意事项

  • Node.js 版本兼容性:VS Code 扩展开发推荐使用 LTS 版本的 Node.js(v18 或 v20)。过高或过低的版本可能导致编译失败或运行时不兼容。使用 nvmfnm 管理多版本 Node.js 是比较好的实践。
  • vsce 安全性:发布扩展时需要提供 Personal Access Token(PAT)。切勿将 PAT 硬编码在脚本中,应通过环境变量、Azure Key Vault 或交互式提示获取。PAT 应设置最小权限和有效期。
  • TypeScript 严格模式:建议在 tsconfig.json 中开启 strict: true。VS Code 扩展 API 的类型定义非常完善,严格模式可以在编译阶段捕获大量潜在问题,减少运行时错误。
  • 扩展清单校验:发布前务必检查 package.json 中的必填字段(namedisplayNamedescriptionversionengines.vscodecategorieskeywordsicon)。缺少这些字段可能导致 Marketplace 审核不通过或扩展显示异常。
  • .vscodeignore 配置:正确配置 .vscodeignore 文件,排除 src/tsconfig.json.eslintrc.* 等开发文件,只打包编译后的 out/ 目录和必要资源,可以有效减小 .vsix 文件体积。
  • CI/CD 集成:将构建和发布脚本集成到 GitHub Actions 或 Azure Pipeline 中,可以实现提交代码后自动编译、测试、打包。发布操作建议设置为手动触发,避免误操作将未就绪的版本推送到 Marketplace。

PowerShell 技能连载 - 模块开发实战

适用于 PowerShell 5.1 及以上版本

PowerShell 模块是代码复用的标准单元。将常用的函数封装为模块,不仅可以在不同脚本中轻松调用,还能方便地分享给团队成员或发布到 PowerShell Gallery。一个结构良好的模块包含清单文件(PSD1)、脚本模块(PSM1)、帮助文档和测试用例。掌握模块开发,是从”写脚本”到”做工程”的关键一步。

本文将介绍模块的创建、清单配置、打包发布的完整流程。

创建模块结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# 创建标准模块目录结构
$moduleName = "Utilities"
$moduleRoot = "$env:USERPROFILE\Documents\PowerShell\Modules\$moduleName"
$version = "1.0.0"

$directories = @(
$moduleRoot
"$moduleRoot\$version"
"$moduleRoot\$version\Public"
"$moduleRoot\$version\Private"
"$moduleRoot\$version\en-US"
)

foreach ($dir in $directories) {
if (-not (Test-Path $dir)) {
New-Item $dir -ItemType Directory -Force | Out-Null
}
}

Write-Host "模块目录已创建:$moduleRoot\$version" -ForegroundColor Green

# 创建私有函数
$privateFunction = @'
function Get-InternalConfig {
[CmdletBinding()]
param()

$configPath = Join-Path $env:APPDATA "Utilities\config.json"

if (Test-Path $configPath) {
return Get-Content $configPath -Raw | ConvertFrom-Json
}

return [PSCustomObject]@{
LogLevel = "Info"
OutputPath = "$env:TEMP\Utilities"
MaxRecords = 1000
}
}
'@

$privateFunction | Set-Content "$moduleRoot\$version\Private\Get-InternalConfig.ps1"

# 创建公共函数
$publicFunctions = @(
@{
Name = "Get-SystemReport"
Content = @'
function Get-SystemReport {
<#
.SYNOPSIS
获取系统信息报告
.DESCRIPTION
收集操作系统、硬件、网络等系统信息并生成报告对象
.OUTPUTS
PSCustomObject
#>
[CmdletBinding()]
param(
[switch]$IncludeNetwork,
[switch]$IncludeServices
)

$config = Get-InternalConfig

$report = [PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
OS = (Get-CimInstance Win32_OperatingSystem).Caption
Version = [System.Environment]::OSVersion.Version.ToString()
TotalMemoryGB = [math]::Round(
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2
)
CPU = (Get-CimInstance Win32_Processor).Name
}

if ($IncludeNetwork) {
$adapters = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.)' }
$report | Add-Member -NotePropertyName Network -NotePropertyValue (
$adapters | Select-Object InterfaceAlias, IPAddress, PrefixLength
)
}

if ($IncludeServices) {
$services = Get-Service | Where-Object { $_.Status -eq 'Running' }
$report | Add-Member -NotePropertyName RunningServices -NotePropertyValue $services.Count
}

return $report
}
'@
}
@{
Name = "Export-SystemReport"
Content = @'
function Export-SystemReport {
<#
.SYNOPSIS
导出系统报告到文件
#>
[CmdletBinding()]
param(
[string]$OutputPath = "$env:TEMP\SystemReport.json",
[switch]$IncludeNetwork,
[switch]$IncludeServices
)

$report = Get-SystemReport -IncludeNetwork:$IncludeNetwork -IncludeServices:$IncludeServices
$report | ConvertTo-Json -Depth 5 | Set-Content $OutputPath -Encoding UTF8
Write-Host "报告已导出到:$OutputPath" -ForegroundColor Green
return $OutputPath
}
'@
}
)

foreach ($func in $publicFunctions) {
$func.Content | Set-Content "$moduleRoot\$version\Public\$($func.Name).ps1"
}

Write-Host "已创建 $($publicFunctions.Count) 个公共函数" -ForegroundColor Green

执行结果示例:

1
2
模块目录已创建:C:\Users\admin\Documents\PowerShell\Modules\Utilities\1.0.0
已创建 2 个公共函数

模块清单与入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 创建 PSM1 入口文件(加载所有函数)
$psm1Content = @'
# 加载私有函数
$private = Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue
foreach ($file in $private) {
. $file.FullName
}

# 加载公共函数
$public = Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue
foreach ($file in $public) {
. $file.FullName
}

# 导出公共函数
Export-ModuleMember -Function $public.BaseName
'@

$psm1Content | Set-Content "$moduleRoot\$version\Utilities.psm1"

# 创建 PSD1 模块清单
$manifestParams = @{
Path = "$moduleRoot\$version\Utilities.psd1"
RootModule = "Utilities.psm1"
ModuleVersion = $version
Author = "Admin"
CompanyName = "IT Department"
Description = "日常运维工具集"
PowerShellVersion = "5.1"
FunctionsToExport = @("Get-SystemReport", "Export-SystemReport")
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
FileList = @("Utilities.psm1", "Utilities.psd1",
"Public\Get-SystemReport.ps1",
"Public\Export-SystemReport.ps1",
"Private\Get-InternalConfig.ps1")
PrivateData = @{
PSData = @{
Tags = @("Utilities", "SystemReport", "Admin")
LicenseUri = "https://opensource.org/licenses/MIT"
ProjectUri = "https://github.com/example/Utilities"
}
}
}

New-ModuleManifest @manifestParams
Write-Host "模块清单已创建" -ForegroundColor Green

# 验证模块
Test-ModuleManifest "$moduleRoot\$version\Utilities.psd1" |
Select-Object Name, Version, Author, Description, ExportedFunctions |
Format-List

# 导入并测试
Import-Module Utilities -Force
Get-Command -Module Utilities | Format-Table Name, CommandType -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
模块清单已创建

Name : Utilities
Version : 1.0.0
Author : Admin
Description : 日常运维工具集
ExportedFunctions : {[Get-SystemReport, Export-SystemReport]}

Name CommandType
---- -----------
Get-SystemReport Function
Export-SystemReport Function

模块发布与版本管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 语义化版本管理
function Update-ModuleVersion {
param(
[Parameter(Mandatory)]
[string]$ManifestPath,

[ValidateSet("Major", "Minor", "Patch")]
[string]$Bump = "Patch"
)

$manifest = Test-ModuleManifest $ManifestPath
$current = [version]$manifest.Version

$newVersion = switch ($Bump) {
"Major" { [version]::new($current.Major + 1, 0, 0) }
"Minor" { [version]::new($current.Major, $current.Minor + 1, 0) }
"Patch" { [version]::new($current.Major, $current.Minor, $current.Build + 1) }
}

# 更新清单中的版本号
$content = Get-Content $ManifestPath -Raw
$content = $content -replace "ModuleVersion = '\d+\.\d+\.\d+'", "ModuleVersion = '$newVersion'"
$content | Set-Content $ManifestPath -Encoding UTF8

Write-Host "版本已更新:$current -> $newVersion" -ForegroundColor Green
return $newVersion
}

# 升级补丁版本
Update-ModuleVersion -ManifestPath "$moduleRoot\$version\Utilities.psd1" -Bump Patch

# 创建 NuGet 包(用于内部分发)
function Publish-ModuleToLocalRepo {
param(
[string]$ModulePath,
[string]$RepositoryPath = "\\fileserver\PSRepository"
)

if (-not (Test-Path $RepositoryPath)) {
New-Item $RepositoryPath -ItemType Directory -Force | Out-Null
}

# 注册本地仓库
if (-not (Get-PSRepository -Name "LocalRepo" -ErrorAction SilentlyContinue)) {
Register-PSRepository -Name "LocalRepo" `
-SourceLocation $RepositoryPath `
-PublishLocation $RepositoryPath `
-InstallationPolicy Trusted
}

Publish-Module -Path $ModulePath -Repository "LocalRepo"
Write-Host "模块已发布到本地仓库:$RepositoryPath" -ForegroundColor Green
}

# Publish-ModuleToLocalRepo -ModulePath "$moduleRoot\$version"

# 模块依赖管理
$dependencyCheck = {
$module = "Utilities"
$installed = Get-Module -ListAvailable -Name $module |
Sort-Object Version -Descending |
Select-Object -First 1

if ($installed) {
Write-Host "$module 版本:$($installed.Version)" -ForegroundColor Green

# 检查依赖模块
$manifest = Test-ModuleManifest $installed.Path
if ($manifest.RequiredModules) {
foreach ($req in $manifest.RequiredModules) {
$reqInstalled = Get-Module -ListAvailable -Name $req.Name
if (-not $reqInstalled) {
Write-Host "缺少依赖:$($req.Name)" -ForegroundColor Red
}
}
}
} else {
Write-Host "未安装模块:$module" -ForegroundColor Yellow
}
}

& $dependencyCheck

执行结果示例:

1
2
版本已更新:1.0.0 -> 1.0.1
Utilities 版本:1.0.1

注意事项

  1. 命名规范:模块名使用 PascalCase,函数名使用 Verb-Noun 格式,动词从 Get-Verb 的批准列表中选取
  2. 清单文件:PSD1 是模块的元数据入口,FunctionsToExport 应显式列出而非使用通配符,提升加载性能
  3. 帮助文档:使用基于注释的帮助(.SYNOPSIS.DESCRIPTION)或外部 MAML 帮助文件,Get-Help 才能正常显示
  4. 兼容性:注意区分 PowerShell 5.1 和 7.x 的差异,模块清单的 PowerShellVersion 应如实标注最低要求
  5. 测试:使用 Pester 框架为公共函数编写单元测试,确保模块升级不破坏现有功能
  6. 发布:发布到 PowerShell Gallery 前使用 Test-ModuleManifestTest-ScriptFileInfo 验证元数据完整性

PowerShell 技能连载 - 模块开发与打包

适用于 PowerShell 5.1 及以上版本

当你发现自己在多个脚本中复制粘贴相同的函数时,就该考虑创建模块了。模块是 PowerShell 代码复用、分发和版本管理的基本单元。一个设计良好的模块不仅方便自己使用,还可以发布到 PowerShell Gallery 供社区使用。本文将讲解从零创建一个完整模块的过程,包括模块清单、函数设计、帮助文档和发布流程。

模块结构设计

一个规范的 PowerShell 模块应遵循以下目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 创建模块骨架
$moduleName = "SystemTools"
$moduleRoot = "C:\Projects\$moduleName"

$directories = @(
$moduleRoot
"$moduleRoot\Public"
"$moduleRoot\Private"
"$moduleRoot\en-US"
"$moduleRoot\Tests"
)

foreach ($dir in $directories) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}

# 创建模块入口文件
$moduleFile = @"
# 导入私有函数
`$privateFunctions = Get-ChildItem -Path "`$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue
foreach (`$func in `$privateFunctions) {
. `$func.FullName
}

# 导入公共函数
`$publicFunctions = Get-ChildItem -Path "`$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue
foreach (`$func in `$publicFunctions) {
. `$func.FullName
}

# 导出公共函数
Export-ModuleMember -Function `$publicFunctions.BaseName
"@

Set-Content -Path "$moduleRoot\$moduleName.psm1" -Value $moduleFile
Write-Host "模块骨架已创建:$moduleRoot" -ForegroundColor Green

执行结果示例:

1
模块骨架已创建:C:\Projects\SystemTools

编写模块函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# 公共函数:获取系统信息
$publicFunc = @'
function Get-SystemInfo {
<#
.SYNOPSIS
获取系统基本信息

.DESCRIPTION
采集操作系统、CPU、内存和磁盘等系统信息

.PARAMETER ComputerName
目标计算机名,默认为本地

.EXAMPLE
Get-SystemInfo
获取本机系统信息

.EXAMPLE
Get-SystemInfo -ComputerName SRV01
获取远程计算机系统信息

.OUTPUTS
PSCustomObject
#>
[CmdletBinding()]
param(
[string]$ComputerName = $env:COMPUTERNAME
)

$os = Get-CimInstance Win32_OperatingSystem -ComputerName $ComputerName
$cpu = Get-CimInstance Win32_Processor -ComputerName $ComputerName |
Select-Object -First 1
$disk = Get-CimInstance Win32_LogicalDisk -ComputerName $ComputerName `
-Filter "DriveType=3"

[PSCustomObject]@{
ComputerName = $ComputerName
OS = $os.Caption
Version = $os.Version
CPU = $cpu.Name
CPUCores = $cpu.NumberOfLogicalProcessors
TotalRAM_GB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
FreeRAM_GB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
Disks = $disk | ForEach-Object {
[PSCustomObject]@{
Drive = $_.DeviceID
FreeGB = [math]::Round($_.FreeSpace / 1GB, 2)
TotalGB = [math]::Round($_.Size / 1GB, 2)
}
}
}
}
'@

Set-Content -Path "$moduleRoot\Public\Get-SystemInfo.ps1" -Value $publicFunc

# 公共函数:测试端口
$testPortFunc = @'
function Test-ServerPort {
<#
.SYNOPSIS
测试远程服务器的 TCP 端口连通性

.EXAMPLE
Test-ServerPort -ComputerName web01 -Port 443
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ComputerName,

[Parameter(Mandatory)]
[int]$Port,

[int]$TimeoutMs = 3000
)

$tcpClient = New-Object System.Net.Sockets.TcpClient
$asyncResult = $tcpClient.BeginConnect($ComputerName, $Port, $null, $null)
$waited = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)

$isOpen = $false
if ($waited) {
try {
$tcpClient.EndConnect($asyncResult)
$isOpen = $true
} catch { $isOpen = $false }
}

$tcpClient.Close()

[PSCustomObject]@{
ComputerName = $ComputerName
Port = $Port
IsOpen = $isOpen
}
}
'@

Set-Content -Path "$moduleRoot\Public\Test-ServerPort.ps1" -Value $testPortFunc
Write-Host "公共函数已创建" -ForegroundColor Green

执行结果示例:

1
公共函数已创建

创建模块清单

模块清单(.psd1)是模块的元数据文件,定义了版本、作者、导出函数等信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$manifestParams = @{
Path = "$moduleRoot\$moduleName.psd1"
RootModule = "$moduleName.psm1"
ModuleVersion = '1.0.0'
GUID = (New-Guid).ToString()
Author = 'DevOps Team'
CompanyName = 'Contoso'
Description = '系统运维工具集 - 提供常用系统管理和诊断函数'
PowerShellVersion = '5.1'
FunctionsToExport = @('Get-SystemInfo', 'Test-ServerPort')
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
FileList = @("$moduleName.psm1", "$moduleName.psd1",
'Public\Get-SystemInfo.ps1', 'Public\Test-ServerPort.ps1')
Tags = @('System', 'Monitoring', 'Diagnostics', 'Tools')
LicenseUri = 'https://opensource.org/licenses/MIT'
ProjectUri = 'https://github.com/contoso/SystemTools'
ReleaseNotes = '初始版本'
}

New-ModuleManifest @manifestParams
Write-Host "模块清单已创建" -ForegroundColor Green

# 验证清单
Test-ModuleManifest -Path "$moduleRoot\$moduleName.psd1"

执行结果示例:

1
2
3
4
5
6
7
8
模块清单已创建

Name : SystemTools
Path : C:\Projects\SystemTools\SystemTools.psd1
Description : 系统运维工具集
ModuleType : Script
Version : 1.0.0
ExportedFunctions : {Get-SystemInfo, Test-ServerPort}

测试与发布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 导入并测试模块
Import-Module "$moduleRoot\$moduleName.psd1" -Force

# 查看导出的命令
Get-Command -Module SystemTools

# 测试函数
Get-SystemInfo

# 发布到 PowerShell Gallery
# Publish-Module -Path $moduleRoot -NuGetApiKey 'your-api-key'

# 也可以打包为 NuGet 包
nuget pack "$moduleRoot\$moduleName.nuspec" -OutputDirectory "C:\Packages"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
CommandType  Name            Version Source
----------- ---- ------- ------
Function Get-SystemInfo 1.0.0 SystemTools
Function Test-ServerPort 1.0.0 SystemTools

ComputerName : DESKTOP-WORK01
OS : Microsoft Windows 11 Pro
Version : 10.0.22631
CPU : Intel(R) Core(TM) i7-13700K
CPUCores : 24
TotalRAM_GB : 31.89
FreeRAM_GB : 8.45

注意事项

  1. 函数命名:遵循 Verb-Noun 命名规范,使用 Get-Verb 查看批准的动词列表
  2. 帮助文档:每个公共函数都应有基于注释的帮助(.SYNOPSIS.DESCRIPTION.EXAMPLE
  3. CmdletBinding:所有高级函数都应添加 [CmdletBinding()] 属性,支持 -Verbose-Debug 等通用参数
  4. 版本号规范:遵循 SemVer(语义化版本号),Major.Minor.Patch
  5. 私有函数:内部辅助函数放在 Private 目录,不导出给用户使用
  6. 兼容性测试:使用 PSScriptAnalyzer 检查代码质量,Pester 编写单元测试

PowerShell 技能连载 - 自定义模块开发与发布

适用于 PowerShell 7.0 及以上版本

模块化开发的意义

当你的 PowerShell 脚本从几十行增长到几百行、甚至跨多个文件协作时,把代码组织成模块就变得尤为重要。模块(Module)是 PowerShell 中代码复用和分发的基本单元,它让你可以将相关函数、类、配置打包在一起,通过一条 Import-Module 命令即可加载使用。

PowerShell Gallery 是微软官方维护的模块仓库,社区已有超过 4000 个模块可供直接安装。将自己编写的模块发布到 PowerShell Gallery,不仅方便团队成员共享,也能让全球 PowerShell 用户受益。本文将从零开始,带你完成一个自定义模块的开发、测试与发布全流程。

规划模块目录结构

一个规范的 PowerShell 模块应当遵循统一的目录结构。模块根目录以模块名命名,内部包含模块清单文件(.psd1)、脚本模块文件(.psm1)、以及按功能分类的函数文件。此外还应包含测试目录和帮助文档。

下面是一个典型模块的目录结构示例:

1
2
3
4
5
6
7
8
9
10
11
12
MyUtils/
├── MyUtils.psd1 # 模块清单
├── MyUtils.psm1 # 模块入口
├── Private/ # 内部函数(不导出)
│ └── _ResolvePath.ps1
├── Public/ # 公开函数(导出给用户)
│ ├── Get-SystemInfo.ps1
│ └── Send-WebNotification.ps1
├── Tests/ # Pester 测试
│ └── Get-SystemInfo.Tests.ps1
└── en-US/ # 帮助文档
└── About_MyUtils.help.txt

这种将 Public 和 Private 函数分离的做法,让模块的公共 API 与内部实现解耦。用户只能调用 Public 目录下的函数,而 Private 目录下的辅助函数对用户不可见,降低了模块的使用复杂度。

创建模块清单

模块清单(.psd1)是模块的元数据文件,定义了模块的版本、作者、导出命令、依赖关系等信息。PowerShell 提供了 New-ModuleManifest cmdlet 来生成这个文件。

以下命令创建一个完整的模块清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$modulePath = Join-Path $HOME "Documents/PowerShell/Modules/MyUtils"

New-ModuleManifest -Path (Join-Path $modulePath "MyUtils.psd1") `
-RootModule "MyUtils.psm1" `
-ModuleVersion "1.0.0" `
-Author "victorwoo" `
-Description "日常运维工具函数集" `
-PowerShellVersion "7.0" `
-FunctionsToExport @("Get-SystemInfo", "Send-WebNotification") `
-CmdletsToExport @() `
-VariablesToExport @() `
-AliasesToExport @() `
-PrivateData @{
PSData = @{
Tags = @("Utils", "SystemInfo", "Notification")
ProjectUri = "https://github.com/victorwoo/MyUtils"
LicenseUri = "https://github.com/victorwoo/MyUtils/blob/main/LICENSE"
ReleaseNotes = "首个正式版本"
}
}

执行结果示例:

1
2
3
4
5
    目录: /Users/victorwoo/Documents/PowerShell/Modules/MyUtils

Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2025/4/23 08:00 2847 MyUtils.psd1

清单中 FunctionsToExport 显式指定了要导出的函数名。这是一个关键设置——如果省略此参数,PowerShell 会导出模块中所有函数,包括你原本不想公开的内部辅助函数。显式控制导出列表是模块设计的最佳实践。

编写模块入口与导出函数

模块入口文件 .psm1 负责加载所有函数文件并控制模块的初始化逻辑。一个推荐的写法是使用点 sourcing(dot sourcing)批量加载 Public 和 Private 目录下的脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# MyUtils.psm1 - 模块入口文件

# 加载 Private 函数
$privateFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Private") `
-Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue

foreach ($file in $privateFiles) {
. $file.FullName
}

# 加载 Public 函数
$publicFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Public") `
-Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue

foreach ($file in $publicFiles) {
. $file.FullName
}

接下来在 Public 目录下编写一个实用的函数 Get-SystemInfo,用于快速获取当前系统的关键信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Public/Get-SystemInfo.ps1

function Get-SystemInfo {
<#
.SYNOPSIS
获取当前系统关键运行信息
.DESCRIPTION
返回操作系统版本、运行时间、内存使用率和磁盘空间等摘要信息
.EXAMPLE
Get-SystemInfo
#>
[CmdletBinding()]
param()

$os = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
$uptime = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$mem = Get-CimInstance Win32_OperatingSystem
$memPercent = [math]::Round(
($mem.TotalVisibleMemorySize - $mem.FreePhysicalMemory) /
$mem.TotalVisibleMemorySize * 100, 1
)

$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
ForEach-Object {
$free = [math]::Round($_.FreeSpace / 1GB, 1)
$total = [math]::Round($_.Size / 1GB, 1)
[PSCustomObject]@{
Drive = $_.DeviceID
Free_GB = $free
Total_GB = $total
Used_Pct = [math]::Round(($total - $free) / $total * 100, 1)
}
}

[PSCustomObject]@{
OS = $os
PowerShell = $PSVersionTable.PSVersion.ToString()
Uptime = $uptime.ToString("dd\.hh\:mm\:ss")
MemoryUsedPct = $memPercent
Disks = $disks
}
}

执行结果示例:

1
2
3
4
5
6
OS            : .NET 8.0.2
PowerShell : 7.4.1
Uptime : 03.14:22:15
MemoryUsedPct : 68.3
Disks : {Drive=C:, Free_GB=89.2, Total_GB=256.0, Used_Pct=65.2}

函数中使用了 CimInstance 而非已弃用的 WmiObject,确保在 PowerShell 7 跨平台场景下有更好的兼容性。[CmdletBinding()] 属性让普通函数获得高级函数的能力,支持 -Verbose-Debug 等通用参数。

编写 Pester 测试

模块的质量保障离不开自动化测试。Pester 是 PowerShell 社区最流行的测试框架,几乎所有的社区模块都使用它来编写单元测试。在将模块发布到 PowerShell Gallery 之前,确保测试通过是必要的步骤。

以下是为 Get-SystemInfo 编写的 Pester 测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Tests/Get-SystemInfo.Tests.ps1

BeforeAll {
Import-Module (Join-Path $PSScriptRoot ".." "MyUtils.psd1") -Force
}

Describe "Get-SystemInfo" {
It "返回一个 PSCustomObject" {
$result = Get-SystemInfo
$result | Should -BeOfType [PSCustomObject]
}

It "包含 OS 属性且类型为字符串" {
$result = Get-SystemInfo
$result.OS | Should -BeOfType [string]
$result.OS.Length | Should -BeGreaterThan 0
}

It "包含 PowerShell 版本信息" {
$result = Get-SystemInfo
$result.PowerShell | Should -Match "^\d+\.\d+"
}

It "内存使用率在 0 到 100 之间" {
$result = Get-SystemInfo
$result.MemoryUsedPct | Should -BeGreaterOrEqual 0
$result.MemoryUsedPct | Should -BeLessOrEqual 100
}

It "返回至少一个磁盘信息" {
$result = Get-SystemInfo
$result.Disks.Count | Should -BeGreaterOrEqual 1
}
}

执行测试的命令和结果:

1
Invoke-Pester -Path ./Tests -Output Detailed

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
Starting discovery in 1 files.
Discovery found 5 tests in 12ms.
Running tests.

┌ Tests/Get-SystemInfo.Tests.ps1
│ √ Get-SystemInfo 返回一个 PSCustomObject (8ms)
│ √ Get-SystemInfo 包含 OS 属性且类型为字符串 (3ms)
│ √ Get-SystemInfo 包含 PowerShell 版本信息 (2ms)
│ √ Get-SystemInfo 内存使用率在 0100 之间 (3ms)
│ √ Get-SystemInfo 返回至少一个磁盘信息 (4ms)

Tests passed: 5, Failed: 0, Skipped: 0, NotRun: 0
Total time: 0.23s

测试覆盖了函数返回值的类型检查、属性存在性验证、以及数值范围的合理性校验。编写 Pester 测试时,应当关注函数的”契约”——即它承诺返回什么,而非内部实现细节,这样当内部逻辑优化时测试不会轻易失败。

当模块开发和测试都完成后,就可以将其发布到 PowerShell Gallery 供他人使用。发布前需要完成两个准备工作:注册 PowerShell Gallery 账号获取 API Key,以及确保模块清单中的元数据完整准确。

使用 Publish-Module 命令即可一键发布:

1
2
3
4
5
6
7
# 首先验证模块清单是否有效
Test-ModuleManifest -Path (Join-Path $modulePath "MyUtils.psd1")

# 发布到 PowerShell Gallery
Publish-Module -Path $modulePath `
-NuGetApiKey "oy2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" `
-Repository "PSGallery"

执行结果示例:

1
2
3
4
5
ModuleName      : MyUtils
ModuleVersion : 1.0.0
Published : 2025-04-23 08:00:00
PublishedBy : victorwoo
Repository : PSGallery

发布成功后,任何用户都可以通过以下命令安装和使用你的模块:

1
2
3
Install-Module -Name MyUtils -Scope CurrentUser
Import-Module MyUtils
Get-SystemInfo

需要注意的是,Publish-Module 要求模块清单中的 PrivateData.PSData 包含 TagsProjectUriLicenseUri 等字段,否则会发布失败。另外,API Key 可以在 powershellgallery.com 的账户设置页面生成,建议使用作用域限定(scoped)的 Key 而非全权限 Key,以降低安全风险。

注意事项

  • 显式导出控制:在 .psd1 中使用 FunctionsToExport 明确列出要导出的函数名,避免内部函数意外暴露。同时设置 CmdletsToExport = @()VariablesToExport = @() 等为空数组,防止默认行为导出不需要的成员。

  • 语义化版本号:模块版本遵循 Major.Minor.Patch 规则。破坏性变更升 Major,新增功能升 Minor,Bug 修复升 Patch。PowerShell Gallery 不允许重复上传同一版本号,每次发布必须递增版本号。

  • API Key 安全:不要将 NuGet API Key 硬编码在脚本中。推荐使用环境变量或 Azure Key Vault 存储,CI/CD 流水线中通过 $env:NUGET_API_KEY 传入。

  • 跨平台兼容性:如果你的模块需要在 Linux 和 macOS 上运行,避免使用 WmiObjectWin32_* 等 Windows 专属类。改用 CimInstance/proc 文件系统读取或条件分支处理平台差异。

  • CI/CD 自动化:在 GitHub Actions 或 Azure Pipelines 中集成 Pester 测试和 Publish-Module,实现提交代码后自动测试、打 Tag 后自动发布的工作流,减少人工操作带来的遗漏。