@yuanchilin/dsh-mailbox 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ # ============================================================================
2
+ # mailbox.psm1 — 通用跨会话文件信箱 v1 (泛化自 mcp/RP 联调工具)
3
+ #
4
+ # 模型: N 个对等参与者, 每人一个信箱目录, 各写各的, 互读对方的。
5
+ # - layout=root (标准): 共享根目录 <root>/<id>/ 每人一个子目录
6
+ # - layout=dirs (兼容旧双目录): 显式 dirs 映射 { id -> 目录 }
7
+ # - 消息格式: { id, from, to, type, topic, payload, ts, reply_to }
8
+ # - 路由: to=<id> 定向 / to=all 广播 (写一份, 各人自取)
9
+ # - seen 去重: 每参与者独立 seen 文件 (默认 <outDir>/.seen.json)
10
+ #
11
+ # 用法:
12
+ # Import-Module <dir>/mailbox.psm1
13
+ # $cfg = Get-MailboxConfig # 配置解析 (env > 文件 > 默认)
14
+ # Send-Mailbox -Cfg $cfg -To "agent-b" -Topic "hello" -Payload @{x=1}
15
+ # $msgs = Recv-Mailbox -Cfg $cfg # 读取新消息 (自动更新 seen)
16
+ # Remove-MailboxMsg -Cfg $cfg -Id $msg.id -InInbox
17
+ # ============================================================================
18
+
19
+ Set-StrictMode -Version Latest
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # 配置解析: 默认值 < 环境变量 < 配置文件 (CLI 参数由调用方覆盖)
23
+ # ---------------------------------------------------------------------------
24
+ function Get-MailboxConfig {
25
+ param([string]$ConfigPath = "")
26
+
27
+ $cfg = @{
28
+ identity = ""
29
+ layout = "root" # root | dirs
30
+ root = ""
31
+ dirs = @{} # layout=dirs: { id -> 目录 }
32
+ participants = @() # layout=root 可选的显式参与者列表 (默认自动扫描)
33
+ intervalSec = 2
34
+ timeoutSec = 0
35
+ seenFile = "" # 默认 <outDir>/.seen.json
36
+ patchRoot = "" # 供示例补丁 handler 使用
37
+ }
38
+
39
+ if ($ConfigPath -eq "") { $ConfigPath = $env:MAILBOX_CONFIG }
40
+ if ($ConfigPath -eq "") { $ConfigPath = Join-Path $PSScriptRoot "mailbox.config.json" }
41
+ if (Test-Path -LiteralPath $ConfigPath) {
42
+ try {
43
+ $file = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json
44
+ foreach ($k in @($cfg.Keys)) {
45
+ if ($null -ne $file.$k) { $cfg[$k] = $file.$k }
46
+ }
47
+ } catch {
48
+ Write-Warning "读取配置失败: $ConfigPath ($($_.Exception.Message))"
49
+ }
50
+ }
51
+
52
+ if ($env:MAILBOX_ID) { $cfg.identity = $env:MAILBOX_ID }
53
+ if ($env:MAILBOX_ROOT) { $cfg.root = $env:MAILBOX_ROOT; $cfg.layout = "root" }
54
+ if ($env:MAILBOX_INTERVAL) { $cfg.intervalSec = [int]$env:MAILBOX_INTERVAL }
55
+ if ($env:MAILBOX_TIMEOUT) { $cfg.timeoutSec = [int]$env:MAILBOX_TIMEOUT }
56
+
57
+ # 默认 root: DSH_HOME 已含 .dsh → 接 mailbox; 否则 ~/.dsh/mailbox (可移植, 不写死路径)
58
+ if ($cfg.layout -eq "root" -and $cfg.root -eq "") {
59
+ $home = $env:DSH_HOME
60
+ if (-not $home) { $home = Join-Path $env:USERPROFILE ".dsh" }
61
+ $cfg.root = Join-Path $home "mailbox"
62
+ }
63
+
64
+ # 规范化: JSON 解析出的 dirs 是 PSCustomObject, participants 可能是 $null/空数组
65
+ if ($cfg.dirs -and $cfg.dirs -isnot [System.Collections.IDictionary]) {
66
+ $h = @{}
67
+ foreach ($p in $cfg.dirs.PSObject.Properties) { $h[$p.Name] = [string]$p.Value }
68
+ $cfg.dirs = $h
69
+ }
70
+ if ($null -eq $cfg.dirs) { $cfg.dirs = @{} }
71
+ $cfg.participants = @(@($cfg.participants) | Where-Object { $_ })
72
+
73
+ return $cfg
74
+ }
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # 目录解析: 返回 @{ Out = <自己的目录>; In = @(<对方目录...>) }
78
+ # ---------------------------------------------------------------------------
79
+ function Resolve-MailboxDirs {
80
+ param($Cfg)
81
+
82
+ if ($Cfg.layout -eq "dirs") {
83
+ # dirs 可能是程序构造的 hashtable 或配置文件解析出的 PSCustomObject, 统一为 hashtable
84
+ $dirMap = @{}
85
+ if ($Cfg.dirs -is [System.Collections.IDictionary]) {
86
+ $dirMap = $Cfg.dirs
87
+ } elseif ($Cfg.dirs) {
88
+ foreach ($p in $Cfg.dirs.PSObject.Properties) { $dirMap[$p.Name] = [string]$p.Value }
89
+ }
90
+ if (-not $dirMap.ContainsKey($Cfg.identity)) {
91
+ throw "layout=dirs 但配置缺少 identity '$($Cfg.identity)' 的目录映射"
92
+ }
93
+ $out = $dirMap[$Cfg.identity]
94
+ $in = @()
95
+ foreach ($k in @($dirMap.Keys)) {
96
+ if ($k -ne $Cfg.identity) { $in += $dirMap[$k] }
97
+ }
98
+ return @{ Out = $out; In = @($in | Select-Object -Unique) }
99
+ }
100
+
101
+ # layout=root
102
+ if (-not $Cfg.root) { throw "layout=root 需要配置 root" }
103
+ if (-not $Cfg.identity) { throw "layout=root 需要 identity (显式配置或按会话自动派生)" }
104
+ $out = Join-Path $Cfg.root $Cfg.identity
105
+ $in = @()
106
+ $participants = @(@($Cfg.participants) | Where-Object { $_ })
107
+ if ($participants.Count -eq 0) {
108
+ $participants = @(Get-ChildItem -LiteralPath $Cfg.root -Directory -ErrorAction SilentlyContinue |
109
+ Where-Object { $_.Name -notlike "_*" -and $_.Name -notlike ".*" } |
110
+ Select-Object -ExpandProperty Name)
111
+ }
112
+ foreach ($p in $participants) {
113
+ if ($p -ne $Cfg.identity -and $p -ne "") { $in += (Join-Path $Cfg.root $p) }
114
+ }
115
+ return @{ Out = $out; In = @($in | Select-Object -Unique) }
116
+ }
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # seen 读写
120
+ # ---------------------------------------------------------------------------
121
+ function Get-MailboxSeenFile {
122
+ param($Cfg)
123
+ if ($Cfg.seenFile -ne "") { return $Cfg.seenFile }
124
+ return Join-Path (Resolve-MailboxDirs $Cfg).Out ".seen.json"
125
+ }
126
+
127
+ function Get-MailboxSeen {
128
+ param($Cfg)
129
+ $f = Get-MailboxSeenFile $Cfg
130
+ $seen = @()
131
+ if (Test-Path -LiteralPath $f) {
132
+ try { $seen = @((Get-Content -LiteralPath $f -Raw | ConvertFrom-Json)) } catch { $seen = @() }
133
+ }
134
+ # 逗号运算符: 防止单元素数组在函数输出边界被解包成裸字符串 (PowerShell 经典坑)
135
+ return ,$seen
136
+ }
137
+
138
+ function Save-MailboxSeen {
139
+ param($Cfg, [string[]]$Seen)
140
+ $f = Get-MailboxSeenFile $Cfg
141
+ $dir = Split-Path $f -Parent
142
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
143
+ # 参数形式 (-InputObject) 保证单元素也输出数组 JSON ["a"], 避免管道解包成 "a"
144
+ $unique = @($Seen | Select-Object -Unique)
145
+ ConvertTo-Json -InputObject $unique | Set-Content -LiteralPath $f -Encoding UTF8
146
+ }
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # 发送: 写到自己的目录 (对方读你的目录)
150
+ # ---------------------------------------------------------------------------
151
+ function Send-Mailbox {
152
+ param(
153
+ [Parameter(Mandatory)][string]$To, # 参与者 id 或 "all"
154
+ [ValidateSet("request","response","notify","reply")][string]$Type = "notify",
155
+ [string]$Topic = "",
156
+ $Payload = @{},
157
+ [string]$ReplyTo = "",
158
+ $Cfg
159
+ )
160
+
161
+ $dirs = Resolve-MailboxDirs $Cfg
162
+ if (-not (Test-Path -LiteralPath $dirs.Out)) { New-Item -ItemType Directory -Force -Path $dirs.Out | Out-Null }
163
+
164
+ $id = "$(Get-Date -Format 'yyyyMMddHHmmss')-$(Get-Random -Minimum 1000 -Maximum 9999)-$([guid]::NewGuid().ToString('N').Substring(0,4))"
165
+ $msg = [ordered]@{
166
+ id = $id
167
+ from = $Cfg.identity
168
+ to = $To
169
+ type = $Type
170
+ topic = $Topic
171
+ payload = $Payload
172
+ ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
173
+ reply_to = $ReplyTo
174
+ }
175
+ $file = Join-Path $dirs.Out "msg_$id.json"
176
+ $msg | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $file -Encoding UTF8
177
+ return $id
178
+ }
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # 接收: 扫描所有对方目录, 取 to=自己 或 to=all 且未 seen 的消息
182
+ # ---------------------------------------------------------------------------
183
+ function Recv-Mailbox {
184
+ param(
185
+ $Cfg,
186
+ [switch]$KeepSeen # 置位时只读不更新 seen (wait 场景由调用方决定)
187
+ )
188
+
189
+ # Get-MailboxSeen 已用逗号保证返回扁平数组, 这里不要再 @() 包装 (会变成嵌套数组)
190
+ $seen = Get-MailboxSeen $Cfg
191
+ $new = @()
192
+ foreach ($dir in (Resolve-MailboxDirs $Cfg).In) {
193
+ if (-not (Test-Path -LiteralPath $dir)) { continue }
194
+ Get-ChildItem -LiteralPath $dir -Filter "msg_*.json" -ErrorAction SilentlyContinue |
195
+ Sort-Object Name |
196
+ ForEach-Object {
197
+ try {
198
+ $m = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json
199
+ if (($m.to -eq $Cfg.identity -or $m.to -eq "all") -and ($seen -notcontains $m.id)) {
200
+ $new += $m
201
+ if (-not $KeepSeen) { $seen += $m.id }
202
+ }
203
+ } catch { }
204
+ }
205
+ }
206
+ if (-not $KeepSeen) { Save-MailboxSeen $Cfg $seen }
207
+ return $new
208
+ }
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # 删除消息: -InInbox 删对方目录(已处理), 默认删自己的目录(已发送)
212
+ # ---------------------------------------------------------------------------
213
+ function Remove-MailboxMsg {
214
+ param(
215
+ [Parameter(Mandatory)][string]$Id,
216
+ $Cfg,
217
+ [switch]$InInbox
218
+ )
219
+
220
+ $dirs = Resolve-MailboxDirs $Cfg
221
+ $dirsToScan = if ($InInbox) { $dirs.In } else { @($dirs.Out) }
222
+ foreach ($dir in $dirsToScan) {
223
+ if (-not (Test-Path -LiteralPath $dir)) { continue }
224
+ Get-ChildItem -LiteralPath $dir -Filter "msg_*.json" -ErrorAction SilentlyContinue |
225
+ ForEach-Object {
226
+ try {
227
+ $m = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json
228
+ if ($m.id -eq $Id) { Remove-Item -LiteralPath $_.FullName -Force; return }
229
+ } catch { }
230
+ }
231
+ }
232
+ }
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # TTL 清理: 删除自己 OutDir 中超过 TtlHours 的已发送消息 (收方应已读过)
236
+ # ---------------------------------------------------------------------------
237
+ function Clear-MailboxTTL {
238
+ param($Cfg, [int]$TtlHours = 24, [switch]$DryRun)
239
+
240
+ $dirs = Resolve-MailboxDirs $Cfg
241
+ if (-not (Test-Path -LiteralPath $dirs.Out)) { return 0 }
242
+ $cutoff = (Get-Date).AddHours(-$TtlHours)
243
+ $removed = 0
244
+ Get-ChildItem -LiteralPath $dirs.Out -Filter "msg_*.json" -ErrorAction SilentlyContinue |
245
+ ForEach-Object {
246
+ if ($_.LastWriteTime -lt $cutoff) {
247
+ if (-not $DryRun) { Remove-Item -LiteralPath $_.FullName -Force }
248
+ $removed++
249
+ }
250
+ }
251
+ return $removed
252
+ }
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # 状态: 身份/目录/各参与者消息数/未读数
256
+ # ---------------------------------------------------------------------------
257
+ function Get-MailboxStatus {
258
+ param($Cfg)
259
+
260
+ $dirs = Resolve-MailboxDirs $Cfg
261
+ $seen = Get-MailboxSeen $Cfg
262
+ $inInfo = @()
263
+ foreach ($dir in $dirs.In) {
264
+ $count = if (Test-Path -LiteralPath $dir) {
265
+ @(Get-ChildItem -LiteralPath $dir -Filter "msg_*.json" -ErrorAction SilentlyContinue).Count
266
+ } else { 0 }
267
+ $inInfo += [pscustomobject]@{ Dir = $dir; MsgCount = $count }
268
+ }
269
+ return [pscustomobject]@{
270
+ identity = $Cfg.identity
271
+ layout = $Cfg.layout
272
+ outDir = $dirs.Out
273
+ outCount = @(Get-ChildItem -LiteralPath $dirs.Out -Filter "msg_*.json" -ErrorAction SilentlyContinue).Count
274
+ seen = @($seen).Count
275
+ inboxes = $inInfo
276
+ }
277
+ }
278
+
279
+ Export-ModuleMember -Function Get-MailboxConfig, Resolve-MailboxDirs, Send-Mailbox, Recv-Mailbox, Remove-MailboxMsg, Clear-MailboxTTL, Get-MailboxStatus
@@ -0,0 +1,105 @@
1
+ # ============================================================================
2
+ # self-test.ps1 — mailbox 泛化工具自测
3
+ # 覆盖: 往返 / 广播 / wait 唤醒 / TTL 清理 / pwsh↔node 互通 / 旧 dirs 布局 / seen 去重
4
+ # 用法: .\self-test.ps1 (全部通过 exit 0)
5
+ # ============================================================================
6
+ $ErrorActionPreference = "Stop"
7
+
8
+ $toolDir = $PSScriptRoot
9
+ $testRoot = Join-Path $toolDir ".selftest"
10
+ if (Test-Path -LiteralPath $testRoot) { Remove-Item -Recurse -Force -LiteralPath $testRoot }
11
+ New-Item -ItemType Directory -Force -Path $testRoot | Out-Null
12
+
13
+ $pass = 0; $fail = 0
14
+ function Check($name, $cond) {
15
+ if ($cond) { Write-Host "PASS $name"; $script:pass++ }
16
+ else { Write-Host "FAIL $name"; $script:fail++ }
17
+ }
18
+
19
+ Import-Module (Join-Path $toolDir "mailbox.psm1") -Force
20
+
21
+ # ---- 配置: root 布局, 三参与者 agent-a / agent-b / rp ----
22
+ $cfgA = Get-MailboxConfig; $cfgA.identity = "agent-a"; $cfgA.root = $testRoot; $cfgA.layout = "root"
23
+ $cfgB = Get-MailboxConfig; $cfgB.identity = "agent-b"; $cfgB.root = $testRoot; $cfgB.layout = "root"
24
+ $cfgR = Get-MailboxConfig; $cfgR.identity = "rp"; $cfgR.root = $testRoot; $cfgR.layout = "root"
25
+ New-Item -ItemType Directory -Force -Path (Join-Path $testRoot "rp") | Out-Null # 让自动扫描包含 rp
26
+
27
+ # 供 CLI 使用的配置文件
28
+ $cfgAFile = Join-Path $testRoot "agent-a.config.json"
29
+ $cfgBFile = Join-Path $testRoot "agent-b.config.json"
30
+ @{ identity="agent-a"; layout="root"; root=$testRoot; dirs=@{}; participants=@(); intervalSec=1; timeoutSec=0; seenFile=""; patchRoot="" } | ConvertTo-Json | Set-Content $cfgAFile -Encoding UTF8
31
+ @{ identity="agent-b"; layout="root"; root=$testRoot; dirs=@{}; participants=@(); intervalSec=1; timeoutSec=0; seenFile=""; patchRoot="" } | ConvertTo-Json | Set-Content $cfgBFile -Encoding UTF8
32
+
33
+ $ps1 = Join-Path $toolDir "mailbox.ps1"
34
+ $mjs = Join-Path $toolDir "mailbox.mjs"
35
+
36
+ # ================= 1. 模块级: 往返 + seen 去重 =================
37
+ $id1 = Send-Mailbox -Cfg $cfgA -To "agent-b" -Topic "hello" -Payload @{ x = 1 }
38
+ $msgs = @(Recv-Mailbox -Cfg $cfgB)
39
+ Check "往返: B 收到 A 的消息" ($msgs.Count -eq 1 -and $msgs[0].from -eq "agent-a" -and $msgs[0].topic -eq "hello")
40
+ Check "往返: payload 正确" ($msgs[0].payload.x -eq 1)
41
+ $msgs2 = @(Recv-Mailbox -Cfg $cfgB)
42
+ Check "seen 去重: 第二次 recv 无新消息" ($msgs2.Count -eq 0)
43
+
44
+ # ================= 2. 广播 to=all =================
45
+ Send-Mailbox -Cfg $cfgR -To "all" -Topic "broadcast" -Payload @{ n = 42 } | Out-Null
46
+ $b1 = @(Recv-Mailbox -Cfg $cfgA)
47
+ $b2 = @(Recv-Mailbox -Cfg $cfgB)
48
+ Check "广播: A 和 B 都收到 to=all" ($b1.Count -eq 1 -and $b2.Count -eq 1 -and $b1[0].topic -eq "broadcast")
49
+
50
+ # ================= 3. wait 唤醒 (pwsh CLI) =================
51
+ Send-Mailbox -Cfg $cfgA -To "agent-b" -Topic "wake" | Out-Null
52
+ $waitOut = & $ps1 wait -Config $cfgBFile -Timeout 5 6>&1 2>&1 | Out-String
53
+ Check "wait: 有新消息时输出唤醒标记" ($waitOut -match "WAKE-UP")
54
+
55
+ # ================= 4. TTL 清理 =================
56
+ $idOld = Send-Mailbox -Cfg $cfgA -To "agent-b" -Topic "old" | Out-Null
57
+ $oldFile = Get-ChildItem -LiteralPath (Join-Path $testRoot "agent-a") -Filter "msg_*.json" | Sort-Object LastWriteTime | Select-Object -Last 1
58
+ $oldFile.LastWriteTime = (Get-Date).AddDays(-2)
59
+ $dry = Clear-MailboxTTL -Cfg $cfgA -TtlHours 24 -DryRun
60
+ Check "TTL dry-run: 计数 1" ($dry -eq 1)
61
+ $real = Clear-MailboxTTL -Cfg $cfgA -TtlHours 24
62
+ Check "TTL: 实际删除 1" ($real -eq 1)
63
+ Check "TTL: 文件已消失" (-not (Test-Path -LiteralPath $oldFile.FullName))
64
+
65
+ # ================= 5. pwsh ↔ node 互通 =================
66
+ & node $mjs send --config $cfgBFile --to agent-a --topic from-node --payload '{"n":2}' 2>&1 | Out-Null
67
+ $recvA = @(Recv-Mailbox -Cfg $cfgA)
68
+ Check "node 发 → pwsh 收" ($recvA.Count -eq 1 -and $recvA[0].from -eq "agent-b" -and $recvA[0].topic -eq "from-node" -and $recvA[0].payload.n -eq 2)
69
+
70
+ Send-Mailbox -Cfg $cfgA -To "agent-b" -Topic "to-node" -Payload @{ k = "v" } | Out-Null
71
+ $nodeRecv = & node $mjs recv --config $cfgBFile --format json 2>&1 | Out-String
72
+ Check "pwsh 发 → node 收" ($nodeRecv -match "to-node")
73
+
74
+ $nodeStatus = & node $mjs status --config $cfgAFile 2>&1 | Out-String
75
+ Check "node status 正常" ($nodeStatus -match "agent-a")
76
+
77
+ # ================= 6. 旧 dirs 布局兼容 (CLI) =================
78
+ $legacyRoot = Join-Path $testRoot "legacy"
79
+ New-Item -ItemType Directory -Force -Path (Join-Path $legacyRoot "mcp") | Out-Null
80
+ New-Item -ItemType Directory -Force -Path (Join-Path $legacyRoot "rp") | Out-Null
81
+ $legacyCfgM = Join-Path $testRoot "legacy-mcp.config.json"
82
+ $legacyCfgR = Join-Path $testRoot "legacy-rp.config.json"
83
+ @{ identity="mcp"; layout="dirs"; root=""; dirs=@{ mcp=(Join-Path $legacyRoot "mcp"); rp=(Join-Path $legacyRoot "rp") }; participants=@(); intervalSec=1; timeoutSec=0; seenFile=""; patchRoot="" } | ConvertTo-Json | Set-Content $legacyCfgM -Encoding UTF8
84
+ @{ identity="rp"; layout="dirs"; root=""; dirs=@{ mcp=(Join-Path $legacyRoot "mcp"); rp=(Join-Path $legacyRoot "rp") }; participants=@(); intervalSec=1; timeoutSec=0; seenFile=""; patchRoot="" } | ConvertTo-Json | Set-Content $legacyCfgR -Encoding UTF8
85
+ & $ps1 send -Config $legacyCfgM -To "rp" -Topic "legacy-test" -Payload '{"L":1}' 2>&1 | Out-Null
86
+ $legacyRecv = & node $mjs recv --config $legacyCfgR --format json 2>&1 | Out-String
87
+ Check "旧 dirs 布局: mcp 发 → rp (node) 收" ($legacyRecv -match "legacy-test")
88
+ Check "旧 dirs 布局: 不创建幽灵目录" (-not (Test-Path -LiteralPath (Join-Path $testRoot "legacy\mcp\.seen.json")))
89
+
90
+ # ================= 7. CLI status / recv 空 =================
91
+ $st = & $ps1 status -Config $cfgAFile 6>&1 2>&1 | Out-String
92
+ Check "pwsh status 正常" ($st -match "agent-a")
93
+ $empty = & $ps1 recv -Config $cfgBFile 6>&1 2>&1 | Out-String
94
+ Check "recv 无新消息提示" ($empty -match "无新消息")
95
+
96
+ # ================= 8. 泛化代码无硬编码路径 =================
97
+ $hardcoded = Select-String -Path (Join-Path $toolDir "mailbox.psm1"),(Join-Path $toolDir "mailbox.ps1"),(Join-Path $toolDir "mailbox.mjs") -Pattern 'D:\\Downloads|Agent\\Soc|Agent\\RP' -ErrorAction SilentlyContinue
98
+ Check "泛化代码无硬编码路径 (Soc/RP)" ($null -eq $hardcoded)
99
+
100
+ # ================= 清理 =================
101
+ Remove-Item -Recurse -Force -LiteralPath $testRoot
102
+ Write-Host ""
103
+ Write-Host "结果: PASS=$pass FAIL=$fail"
104
+ if ($fail -gt 0) { exit 1 }
105
+ exit 0