Skip to content

Commit f2b24b6

Browse files
committed
refactor(psutils): 抽取配置路径解析助手
1 parent a5e11df commit f2b24b6

9 files changed

Lines changed: 236 additions & 104 deletions

File tree

.trellis/spec/psutils/package/shared-config-resolver.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
- Conversion helpers:
2525
- `ConvertTo-ConfigHashtable -InputObject <object>`
2626
- `Get-ConfigValue -Values <hashtable> -Name <string> [-DefaultValue <object>]`
27+
- `Resolve-ConfigEnvPlaceholder -Value <string> -Context <string>`
28+
- `Resolve-ConfigPath -Path <string> -BasePath <string> -Context <string>`
2729
- `ConvertTo-ConfigKeyName -Name <string>`
2830
- `ConvertFrom-ConfigCliParameters -Parameters <hashtable> [-ExcludeKeys <string[]>]`
2931
- Scoped env:
@@ -39,6 +41,8 @@
3941
- `CliParameters` converts explicit PowerShell parameters to snake_case keys and skips `$null`、empty strings and `ExcludeKeys`.
4042
- `MarkdownFrontMatter` returns parsed metadata plus `__content` for the Markdown body.
4143
- `Get-ConfigValue` performs shallow case-insensitive lookup only; it must not expand paths, environment variables, nested paths, or normalize key names.
44+
- `Resolve-ConfigEnvPlaceholder` expands `${VAR}` and `%VAR%`; missing `${VAR}` throws with context instead of silently preserving the placeholder.
45+
- `Resolve-ConfigPath` expands env placeholders, supports `~`, resolves relative paths against `BasePath`, and returns an absolute path. It does not validate existence or create directories.
4246
- Missing file sources return an empty table by default; `-ErrorOnMissing` changes that to `配置文件不存在: <path>`.
4347
- `Invoke-WithScopedEnvironment` must restore overwritten variables and remove newly created variables even when the script block throws.
4448
- `psutils/modules/config.psm1` must export public resolver functions and must not contain a second implementation of the parser.
@@ -54,6 +58,8 @@
5458
| Missing file source with `-ErrorOnMissing` | Throw `配置文件不存在: <path>` |
5559
| Unknown source `Type` | Throw `不支持的配置来源类型` |
5660
| CLI parameter value is `$null` or whitespace | Omit it from merged config |
61+
| `${VAR}` placeholder references a missing env var | Throw `环境变量未设置: VAR(context)` |
62+
| `Resolve-ConfigPath` receives an empty path | Throw `路径配置不能为空: context` |
5763

5864
### 5. Good/Base/Bad Cases
5965

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# design: config path placeholder extraction
2+
3+
## Scope
4+
5+
本任务抽取通用“配置路径解析”能力到 `psutils/modules/config.psm1`。公共 API 只处理字符串路径与环境变量 placeholder,不包含 skills、GitHub release、agent、ctx7 或安装计划语义。
6+
7+
首个接入调用方是 `ai/skills/Install-Skills.ps1`。GitHub CLI 安装器和 rclone 等脚本只作为设计参考,后续单独迁移。
8+
9+
## Public API
10+
11+
新增 `Resolve-ConfigEnvPlaceholder`
12+
13+
* 入参:`Value``Context`
14+
* 支持 `${VAR}`,缺失时抛出 `环境变量未设置: <name>(<context>)`
15+
* 支持平台原生 `%VAR%`,通过 `[Environment]::ExpandEnvironmentVariables`
16+
* 返回展开后的字符串
17+
18+
新增 `Resolve-ConfigPath`
19+
20+
* 入参:`Path``BasePath``Context`
21+
* 空白路径抛出 `路径配置不能为空: <context>`
22+
* 先调用 `Resolve-ConfigEnvPlaceholder`
23+
* 支持 `~``~/...``~\...`
24+
* 相对路径基于 `BasePath`
25+
* 返回 `[System.IO.Path]::GetFullPath(...)`
26+
27+
## Compatibility
28+
29+
保持 `Install-Skills.ps1` 当前行为:
30+
31+
* `${VAR}` 缺失时抛错。
32+
* `%VAR%` 保持 .NET 环境变量展开语义。
33+
* `~` 缺失用户主目录时抛错。
34+
* 所有相对路径仍基于配置文件目录或调用方传入的 base path。
35+
36+
## Tradeoffs
37+
38+
不在本轮支持默认值、可选路径、存在性检查或目录创建。这些属于调用方业务规则,公共 helper 只返回解析后的路径字符串。
39+
40+
## Rollback
41+
42+
如迁移后发现差异,可恢复 `Install-Skills.ps1` 私有 `Resolve-SkillsEnvPlaceholder` / `Resolve-SkillsPath`,公共 API 作为向后兼容新增保留。
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# implement: config path placeholder extraction
2+
3+
## Checklist
4+
5+
1. 更新 `psutils/src/config/convert.ps1`
6+
* 新增 `Resolve-ConfigEnvPlaceholder`
7+
* 新增 `Resolve-ConfigPath`
8+
* 公共函数补充中文 comment-based help。
9+
2. 更新 `psutils/modules/config.psm1`
10+
* 导出新增函数。
11+
3. 更新 `psutils/psutils.psd1`
12+
* 将新增函数加入 `FunctionsToExport`
13+
4. 更新 `psutils/tests/config.Tests.ps1`
14+
* 覆盖 `${VAR}` 展开。
15+
* 覆盖缺失 `${VAR}` 抛错。
16+
* 覆盖 `~` 展开。
17+
* 覆盖相对路径基于 `BasePath`
18+
* 覆盖空路径抛错。
19+
5. 更新 `ai/skills/Install-Skills.ps1`
20+
* 删除 `Resolve-SkillsEnvPlaceholder`
21+
* 删除 `Resolve-SkillsPath`
22+
* 改用 `Resolve-ConfigPath`
23+
6. 验证
24+
* `pnpm exec pwsh -NoProfile -File ./scripts/pwsh/devops/Invoke-PesterMode.ps1 -Mode serial -Path ./psutils/tests/config.Tests.ps1`
25+
* `pnpm exec pwsh -NoProfile -File ./scripts/pwsh/devops/Invoke-PesterMode.ps1 -Mode serial -Path ./tests/SkillsInstaller.Tests.ps1`
26+
* `pnpm qa`
27+
* `pnpm test:pwsh:all`
28+
29+
## Review Gate
30+
31+
确认本轮没有修改 GitHub CLI 安装器、rclone 或 skills 私有模块拆分逻辑;它们留到后续独立任务。

.trellis/tasks/05-20-skills-config-path-placeholder-extraction/task.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "skills-config-path-placeholder-extraction",
44
"title": "skills config path placeholder extraction",
55
"description": "",
6-
"status": "planning",
6+
"status": "in_progress",
77
"dev_type": null,
88
"scope": null,
99
"package": null,

ai/skills/Install-Skills.ps1

Lines changed: 4 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -362,104 +362,6 @@ function Test-SkillsDirectoryCheck {
362362
return $false
363363
}
364364

365-
function Resolve-SkillsEnvPlaceholder {
366-
<#
367-
.SYNOPSIS
368-
展开路径中的环境变量占位符。
369-
370-
.DESCRIPTION
371-
支持 `${NAME}` 与平台原生 `%NAME%` 形式。`${NAME}` 缺失时抛错,
372-
防止本地 skill 路径意外解析到错误位置。
373-
374-
.PARAMETER Value
375-
原始路径字符串。
376-
377-
.PARAMETER Context
378-
当前配置位置,用于错误提示。
379-
380-
.OUTPUTS
381-
string。展开后的路径文本。
382-
#>
383-
[CmdletBinding()]
384-
param(
385-
[AllowEmptyString()]
386-
[string]$Value,
387-
388-
[Parameter(Mandatory)]
389-
[string]$Context
390-
)
391-
392-
$pattern = '\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
393-
foreach ($match in [regex]::Matches($Value, $pattern)) {
394-
$envName = $match.Groups[1].Value
395-
if ($null -eq [Environment]::GetEnvironmentVariable($envName, 'Process')) {
396-
throw "环境变量未设置: $envName$Context"
397-
}
398-
}
399-
400-
$resolved = [regex]::Replace($Value, $pattern, {
401-
param($Match)
402-
$envName = $Match.Groups[1].Value
403-
return [Environment]::GetEnvironmentVariable($envName, 'Process')
404-
})
405-
406-
return [Environment]::ExpandEnvironmentVariables($resolved)
407-
}
408-
409-
function Resolve-SkillsPath {
410-
<#
411-
.SYNOPSIS
412-
将配置中的路径解析为绝对路径。
413-
414-
.PARAMETER Path
415-
原始路径配置值。
416-
417-
.PARAMETER BasePath
418-
相对路径解析基准目录。
419-
420-
.PARAMETER Context
421-
当前配置位置,用于错误提示。
422-
423-
.OUTPUTS
424-
string。解析后的绝对路径。
425-
#>
426-
[CmdletBinding()]
427-
param(
428-
[Parameter(Mandatory)]
429-
[AllowEmptyString()]
430-
[string]$Path,
431-
432-
[Parameter(Mandatory)]
433-
[string]$BasePath,
434-
435-
[Parameter(Mandatory)]
436-
[string]$Context
437-
)
438-
439-
if ([string]::IsNullOrWhiteSpace($Path)) {
440-
throw "路径配置不能为空: $Context"
441-
}
442-
443-
$expanded = Resolve-SkillsEnvPlaceholder -Value $Path.Trim() -Context $Context
444-
if ($expanded -eq '~' -or $expanded.StartsWith('~/') -or $expanded.StartsWith('~\')) {
445-
$home = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
446-
if ([string]::IsNullOrWhiteSpace($home)) {
447-
throw "无法解析用户主目录: $Context"
448-
}
449-
450-
$expanded = if ($expanded -eq '~') { $home } else { Join-Path $home $expanded.Substring(2) }
451-
}
452-
453-
$combined = if ([System.IO.Path]::IsPathRooted($expanded)) {
454-
$expanded
455-
}
456-
else {
457-
Join-Path $BasePath $expanded
458-
}
459-
460-
return [System.IO.Path]::GetFullPath($combined)
461-
}
462-
463365
function Resolve-SkillsProjectRoot {
464366
<#
465367
.SYNOPSIS
@@ -488,7 +390,7 @@ function Resolve-SkillsProjectRoot {
488390
)
489391

490392
if (-not [string]::IsNullOrWhiteSpace($ProjectPath)) {
491-
$resolved = Resolve-SkillsPath -Path $ProjectPath -BasePath $BasePath -Context 'projectPath'
393+
$resolved = Resolve-ConfigPath -Path $ProjectPath -BasePath $BasePath -Context 'projectPath'
492394
if (-not (Test-Path -LiteralPath $resolved -PathType Container)) {
493395
throw "projectPath 不存在或不是目录: $resolved"
494396
}
@@ -755,7 +657,7 @@ function Resolve-SkillsSource {
755657
$sourceType = [string](Get-ConfigValue -Values $Item -Name 'sourceType' -DefaultValue '')
756658
$isLocal = Test-SkillsLocalSource -Source $source -SourceType $sourceType
757659
$resolvedSource = if ($isLocal) {
758-
$localPath = Resolve-SkillsPath -Path $source -BasePath $BasePath -Context "$SkillName.source"
660+
$localPath = Resolve-ConfigPath -Path $source -BasePath $BasePath -Context "$SkillName.source"
759661
$skillFile = Join-Path $localPath 'SKILL.md'
760662
if (-not (Test-Path -LiteralPath $skillFile -PathType Leaf)) {
761663
throw "本地 skill 缺少 SKILL.md: $localPath"
@@ -889,7 +791,7 @@ function ConvertTo-SkillsCommandPlan {
889791
$BasePath
890792
}
891793
else {
892-
Resolve-SkillsPath -Path $workingDirectoryValue -BasePath $BasePath -Context "$OwnerName.commands[$index].workingDirectory"
794+
Resolve-ConfigPath -Path $workingDirectoryValue -BasePath $BasePath -Context "$OwnerName.commands[$index].workingDirectory"
893795
}
894796

895797
$plans.Add([pscustomobject]@{
@@ -992,7 +894,7 @@ function New-SkillsPlanFromConfig {
992894
$Config.BasePath
993895
}
994896
else {
995-
Resolve-SkillsPath -Path $workingDirectoryValue -BasePath $Config.BasePath -Context "$toolName.workingDirectory"
897+
Resolve-ConfigPath -Path $workingDirectoryValue -BasePath $Config.BasePath -Context "$toolName.workingDirectory"
996898
}
997899

998900
$toolPlan = [pscustomobject]@{

psutils/modules/config.psm1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Export-ModuleMember -Function @(
1818
'Invoke-WithScopedEnvironment'
1919
'ConvertTo-ConfigHashtable'
2020
'Get-ConfigValue'
21+
'Resolve-ConfigEnvPlaceholder'
22+
'Resolve-ConfigPath'
2123
'ConvertTo-ConfigKeyName'
2224
'ConvertFrom-ConfigCliParameters'
2325
'Read-ConfigPowerShellDataFile'

psutils/psutils.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
# 命令探测模块 (commandDiscovery.psm1)
106106
'Find-ExecutableCommand',
107107
# 配置管理模块 (config.psm1)
108-
'Resolve-ConfigSources', 'Invoke-WithScopedEnvironment', 'ConvertTo-ConfigHashtable', 'Get-ConfigValue', 'ConvertTo-ConfigKeyName', 'ConvertFrom-ConfigCliParameters',
108+
'Resolve-ConfigSources', 'Invoke-WithScopedEnvironment', 'ConvertTo-ConfigHashtable', 'Get-ConfigValue', 'Resolve-ConfigEnvPlaceholder', 'Resolve-ConfigPath', 'ConvertTo-ConfigKeyName', 'ConvertFrom-ConfigCliParameters',
109109
'Read-ConfigPowerShellDataFile', 'Read-ConfigMarkdownFrontMatter',
110110
# 环境管理模块 (env.psm1)
111111
'Get-Dotenv', 'Install-Dotenv', 'Import-EnvPath', 'Set-EnvPath', 'Add-EnvPath', 'Get-EnvParam', 'Remove-FromEnvPath', 'Sync-PathFromBash',

psutils/src/config/convert.ps1

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,115 @@ function Get-ConfigValue {
9191
return $DefaultValue
9292
}
9393

94+
<#
95+
.SYNOPSIS
96+
展开配置字符串中的环境变量占位符。
97+
98+
.DESCRIPTION
99+
支持 `${VAR_NAME}` 与平台原生 `%VAR_NAME%` 形式。`${...}` 缺失时抛错,
100+
避免路径配置因为未设置环境变量而静默落到错误目录。
101+
102+
.PARAMETER Value
103+
待解析的字符串。
104+
105+
.PARAMETER Context
106+
当前配置位置,用于错误提示。
107+
108+
.OUTPUTS
109+
string
110+
返回环境变量展开后的字符串。
111+
#>
112+
function Resolve-ConfigEnvPlaceholder {
113+
[CmdletBinding()]
114+
param(
115+
[AllowEmptyString()]
116+
[string]$Value,
117+
118+
[Parameter(Mandatory)]
119+
[string]$Context
120+
)
121+
122+
$pattern = '\$\{([A-Za-z_][A-Za-z0-9_]*)\}'
123+
foreach ($match in [regex]::Matches($Value, $pattern)) {
124+
$envName = $match.Groups[1].Value
125+
if ($null -eq [Environment]::GetEnvironmentVariable($envName, 'Process')) {
126+
throw "环境变量未设置: $envName$Context"
127+
}
128+
}
129+
130+
$resolved = [regex]::Replace($Value, $pattern, {
131+
param($Match)
132+
$envName = $Match.Groups[1].Value
133+
return [Environment]::GetEnvironmentVariable($envName, 'Process')
134+
})
135+
136+
return [Environment]::ExpandEnvironmentVariables($resolved)
137+
}
138+
139+
<#
140+
.SYNOPSIS
141+
将配置路径解析为绝对路径。
142+
143+
.DESCRIPTION
144+
处理环境变量占位符、用户主目录 `~` 和相对路径。相对路径按调用方传入的
145+
`BasePath` 解析,适合配置文件随仓库移动的场景。
146+
147+
.PARAMETER Path
148+
原始路径配置值。
149+
150+
.PARAMETER BasePath
151+
相对路径解析基准目录。
152+
153+
.PARAMETER Context
154+
当前配置位置,用于错误提示。
155+
156+
.OUTPUTS
157+
string
158+
返回解析后的绝对路径。
159+
#>
160+
function Resolve-ConfigPath {
161+
[CmdletBinding()]
162+
param(
163+
[Parameter(Mandatory)]
164+
[AllowEmptyString()]
165+
[string]$Path,
166+
167+
[Parameter(Mandatory)]
168+
[string]$BasePath,
169+
170+
[Parameter(Mandatory)]
171+
[string]$Context
172+
)
173+
174+
if ([string]::IsNullOrWhiteSpace($Path)) {
175+
throw "路径配置不能为空: $Context"
176+
}
177+
178+
$expanded = Resolve-ConfigEnvPlaceholder -Value $Path.Trim() -Context $Context
179+
if ($expanded -eq '~' -or $expanded.StartsWith('~/') -or $expanded.StartsWith('~\')) {
180+
$userHome = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
181+
if ([string]::IsNullOrWhiteSpace($userHome)) {
182+
throw "无法解析用户主目录: $Context"
183+
}
184+
185+
$expanded = if ($expanded -eq '~') {
186+
$userHome
187+
}
188+
else {
189+
Join-Path $userHome $expanded.Substring(2)
190+
}
191+
}
192+
193+
$combined = if ([System.IO.Path]::IsPathRooted($expanded)) {
194+
$expanded
195+
}
196+
else {
197+
Join-Path $BasePath $expanded
198+
}
199+
200+
return [System.IO.Path]::GetFullPath($combined)
201+
}
202+
94203
<#
95204
.SYNOPSIS
96205
将配置键名转换为下划线风格。

0 commit comments

Comments
 (0)