@prismshadow/penguin-server 0.2.7 → 0.2.8

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.
Files changed (58) hide show
  1. package/README.md +4 -4
  2. package/dist/api/types.d.ts +389 -18
  3. package/dist/auth-token.d.ts +29 -0
  4. package/dist/auth-token.js +51 -0
  5. package/dist/auth-token.js.map +1 -0
  6. package/dist/{chunk-WKJ3ANJP.js → chunk-622KL7BQ.js} +92 -381
  7. package/dist/chunk-622KL7BQ.js.map +1 -0
  8. package/dist/chunk-K4ZSUT72.js +34 -0
  9. package/dist/chunk-K4ZSUT72.js.map +1 -0
  10. package/dist/chunk-UGAFPYVX.js +374 -0
  11. package/dist/chunk-UGAFPYVX.js.map +1 -0
  12. package/dist/extension/index.d.ts +212 -0
  13. package/dist/extension/index.js +1 -0
  14. package/dist/extension/index.js.map +1 -0
  15. package/dist/index.js +5641 -1299
  16. package/dist/index.js.map +1 -1
  17. package/dist/initial-password.d.ts +9 -12
  18. package/dist/initial-password.js +3 -7
  19. package/dist/install.ps1 +729 -0
  20. package/dist/install.sh +785 -0
  21. package/dist/reset-admin-password.d.ts +4 -7
  22. package/dist/reset-admin-password.js +11 -9
  23. package/dist/reset-admin-password.js.map +1 -1
  24. package/dist/secret-file.d.ts +3 -0
  25. package/dist/secret-file.js +17 -0
  26. package/dist/secret-file.js.map +1 -0
  27. package/package.json +19 -3
  28. package/web-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  29. package/web-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  30. package/web-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  31. package/web-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  32. package/web-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  33. package/web-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  34. package/web-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  35. package/web-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  36. package/web-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  37. package/web-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/web-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/web-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/web-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/web-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  42. package/web-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  43. package/web-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  44. package/web-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  45. package/web-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  46. package/web-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  47. package/web-dist/assets/{addon-fit-BXTX0Xqu.js → addon-fit-DHOzI3Ur.js} +1 -1
  48. package/web-dist/assets/{addon-web-links-KebkunbU.js → addon-web-links-DC7759H1.js} +1 -1
  49. package/web-dist/assets/{highlighter-B3HctNDB.js → highlighter-CGPO6Q4b.js} +1 -1
  50. package/web-dist/assets/index-CaFDHl-w.js +451 -0
  51. package/web-dist/assets/index-D8Bk9LtJ.css +1 -0
  52. package/web-dist/assets/{xterm-fEUfDV0F.js → xterm-DY5ko-BU.js} +1 -1
  53. package/web-dist/index.html +2 -2
  54. package/dist/chunk-UUJCF46V.js +0 -50
  55. package/dist/chunk-UUJCF46V.js.map +0 -1
  56. package/dist/chunk-WKJ3ANJP.js.map +0 -1
  57. package/web-dist/assets/index-BbLm3DrY.css +0 -1
  58. package/web-dist/assets/index-DwgTjTHO.js +0 -189
@@ -0,0 +1,729 @@
1
+ # PenguinHarness one-line installer for Windows.
2
+ #
3
+ # irm https://penguin.ooo/install.ps1 | iex
4
+ #
5
+ # Options:
6
+ # $env:PENGUIN_VERSION = "vX.Y.Z" choose a version (same as -Version vX.Y.Z); a published Release
7
+ # installer defaults to its own version, an unstamped source copy to latest
8
+ # $env:PENGUIN_INSTALL_DIR = "<dir>" install dir; default $env:USERPROFILE\.penguin
9
+ # $env:PENGUIN_ARCHIVE = "<file>" install a local Release zip without network access (same as -ArchivePath)
10
+ # $env:PENGUIN_DOWNLOAD_SOURCE = "auto|oss|github" choose the online source; default auto
11
+ # (speed-probed, with the same-version other source as fallback)
12
+ # $env:PENGUIN_DOWNLOAD_SPEED_PROBE = "0" disable same-version OSS/GitHub probe timing in auto mode
13
+ # $env:PENGUIN_DOWNLOAD_BASE_URL = "https://..." exact online asset directory selected by the stable forwarder
14
+ # $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = "https://..." fallback for PENGUIN_DOWNLOAD_BASE_URL
15
+ #
16
+ # Each Release attaches exactly one Windows artifact: penguin-win32-x64.zip, a shallow installer
17
+ # bundle holding install.cmd, this script, the program payload (payload.zip) and the payload's
18
+ # checksum. Online installs download that bundle and verify it against its published .sha256;
19
+ # offline installs transfer the same single file, extract it once (the outer layer is flat, so
20
+ # no deep paths are created) and double-click install.cmd. Both paths verify the payload
21
+ # checksum sealed inside the bundle, then expand the payload straight into the short staging
22
+ # directory. Releases up to v0.1.5 shipped the program tree directly (top-level penguin\);
23
+ # such zips are still accepted, from -Version pins and -ArchivePath files alike.
24
+ #
25
+ # There is no -Universal on Windows: where the zip is unsuitable, install Node.js >= 24 and run
26
+ # `npm install -g @prismshadow/penguin-cli` instead.
27
+ #
28
+ # The data dir (%USERPROFILE%\.penguin\data) sits under the install home but is never touched by
29
+ # reinstall/upgrade (which only replace bin/lib/web/node/git). Upgrading = re-running this installer.
30
+ #
31
+ # Docs: https://penguin.ooo/docs/quickstart-cli
32
+ param(
33
+ [string]$Version = "",
34
+ [string]$InstallDir = "",
35
+ [string]$ArchivePath = ""
36
+ )
37
+
38
+ $ErrorActionPreference = "Stop"
39
+ $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering slows downloads massively on PS 5.1
40
+
41
+ $Repo = "https://github.com/Prism-Shadow/penguin-harness"
42
+ $OssOrigin = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com"
43
+ $OssReleaseRoot = "$OssOrigin/releases"
44
+ $GitHubReleaseRoot = "$Repo/releases/download"
45
+ $GitHubLatestBase = "$Repo/releases/latest/download"
46
+ $Asset = "penguin-win32-x64.zip"
47
+ # Auto-mode source selection, one rule shared by install.ps1, install.sh and the download page on
48
+ # penguin.ooo (packages/landing/src/lib/download-source.ts):
49
+ #
50
+ # 1. Measure GitHub on the release's large probe file. At or above
51
+ # $SpeedProbeGitHubMinBytesPerSecond it wins outright and OSS is never touched.
52
+ # 2. Only below that is OSS measured, and it takes over only when it is more than
53
+ # $SpeedProbeOssSwitchRatio of GitHub — a mirror that is merely a little quicker does not
54
+ # justify its bandwidth bill, and a slow GitHub download still resumes.
55
+ #
56
+ # GitHub is the free source, so every tie and every unmeasurable comparison stays there. The two
57
+ # constants are duplicated because these three implementations cannot import from each other — an
58
+ # installer is a standalone file fetched over the network. Change them in all three at once, which
59
+ # scripts/test-installer.sh pins.
60
+ #
61
+ # The total budget covers the whole probe: manifest, the small reachability pair, and up to two
62
+ # large probes. It is deliberately the sum of their caps, so the second large probe always gets its
63
+ # full window rather than being squeezed into declaring a healthy mirror unreachable.
64
+ $SpeedProbeManifestTimeoutSeconds = 5
65
+ $SpeedProbeSmallTimeoutSeconds = 5
66
+ $SpeedProbeLargeTimeoutSeconds = 8
67
+ $SpeedProbeTotalTimeoutSeconds = 26
68
+ $SpeedProbeGitHubMinBytesPerSecond = 262144
69
+ $SpeedProbeOssSwitchRatio = 1.5
70
+ $PayloadName = "payload.zip"
71
+ # The release workflow replaces this token with the immutable tag before publishing both the
72
+ # standalone installer and the copy sealed inside the Windows bundle.
73
+ $EmbeddedReleaseVersion = "__PENGUIN_RELEASE_VERSION__"
74
+
75
+ function Fail([string]$Message) {
76
+ # `throw` rather than `exit`: the penguin.ooo forwarder runs this installer as an in-memory
77
+ # script block (see packages/landing/public/install.ps1), where `exit` would terminate the
78
+ # user's whole PowerShell session. `throw` aborts cleanly in both file and script-block runs.
79
+ throw "error: $Message"
80
+ }
81
+
82
+ # Lists a zip's top-level entry names without extracting (PS 5.1-safe; Expand-Archive itself
83
+ # uses the same .NET type). Used to tell an installer bundle (contains payload.zip) from a
84
+ # program archive (payload.zip itself, or a pre-0.1.6 release zip with a top-level penguin\).
85
+ function Get-ZipEntryNames([string]$ZipPath) {
86
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
87
+ $Zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
88
+ try {
89
+ return @($Zip.Entries | ForEach-Object { $_.FullName })
90
+ } finally {
91
+ $Zip.Dispose()
92
+ }
93
+ }
94
+
95
+ # Verifies a file against a sha256sum-format checksum file (`<hex> <filename>`; the first
96
+ # token is the hash). Checksums are never optional: every install path either downloads the
97
+ # published .sha256 or reads the one sealed inside the bundle.
98
+ function Assert-Sha256([string]$FilePath, [string]$ShaPath, [string]$Label) {
99
+ $Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
100
+ if (-not $Expected) { Fail "checksum file is empty or malformed: $ShaPath" }
101
+ $Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $FilePath).Hash
102
+ if ($Actual -ine $Expected) { Fail "checksum mismatch for $([IO.Path]::GetFileName($FilePath))." }
103
+ Write-Host "$Label checksum OK."
104
+ }
105
+
106
+ function Assert-HttpsUrl([string]$Name, [string]$Value) {
107
+ try { $Uri = [Uri]$Value } catch { Fail "$Name is not a valid URL" }
108
+ if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -ne "https") {
109
+ Fail "$Name must be an absolute HTTPS URL"
110
+ }
111
+ }
112
+
113
+ function Test-ReleaseTag([string]$Value) {
114
+ return $Value -match '^v[0-9A-Za-z][0-9A-Za-z._-]*$'
115
+ }
116
+
117
+ function Try-DownloadFile([string]$Uri, [string]$OutFile, [int]$TimeoutSec) {
118
+ try {
119
+ Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec $TimeoutSec | Out-Null
120
+ return $true
121
+ } catch {
122
+ Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue
123
+ return $false
124
+ }
125
+ }
126
+
127
+ function Get-SpeedProbeTimeoutSec([DateTime]$Deadline, [int]$MaxSec) {
128
+ $Remaining = [int][Math]::Ceiling(($Deadline - [DateTime]::UtcNow).TotalSeconds)
129
+ if ($Remaining -le 0) { return 0 }
130
+ return [Math]::Min($MaxSec, $Remaining)
131
+ }
132
+
133
+ function Get-OssLatestTag([string]$ManifestPath) {
134
+ Remove-Item -LiteralPath $ManifestPath -Force -ErrorAction SilentlyContinue
135
+ try {
136
+ Invoke-WebRequest -Uri "$OssOrigin/latest.json" -OutFile $ManifestPath -UseBasicParsing -TimeoutSec 8 | Out-Null
137
+ } catch {
138
+ Remove-Item -LiteralPath $ManifestPath -Force -ErrorAction SilentlyContinue
139
+ return ""
140
+ }
141
+ try {
142
+ $Manifest = [IO.File]::ReadAllText($ManifestPath, [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json
143
+ $CandidateTag = [string]$Manifest.tag
144
+ $CandidateBase = ([string]$Manifest.releaseBaseUrl).TrimEnd('/')
145
+ if ([int]$Manifest.schemaVersion -eq 1 -and
146
+ (Test-ReleaseTag $CandidateTag) -and
147
+ $CandidateBase -eq "$OssReleaseRoot/$CandidateTag") {
148
+ return $CandidateTag
149
+ }
150
+ } catch {
151
+ }
152
+ return ""
153
+ }
154
+
155
+ function Get-DownloadSourceLabel([string]$BaseUrl) {
156
+ try { $HostName = ([Uri]$BaseUrl).Host } catch { return "configured mirror" }
157
+ if ($HostName -like "*.aliyuncs.com") { return "OSS mirror" }
158
+ if ($HostName -eq "github.com") { return "GitHub" }
159
+ return "configured mirror"
160
+ }
161
+
162
+ function Get-ReleasePair(
163
+ [string]$BaseUrl,
164
+ [string]$ZipPath,
165
+ [string]$ShaPath
166
+ ) {
167
+ $Label = Get-DownloadSourceLabel $BaseUrl
168
+ Write-Host "Downloading $Asset from $Label ..."
169
+ Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue
170
+ try {
171
+ Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing
172
+ Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
173
+ return $true
174
+ } catch {
175
+ Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue
176
+ return $false
177
+ }
178
+ }
179
+
180
+ function Test-SafeReleaseAssetName([string]$Value) {
181
+ return $Value -match '^[A-Za-z0-9._+-]+$' -and -not $Value.Contains('..')
182
+ }
183
+
184
+ function Read-ReleaseDownloadManifest([string]$Tag, [string]$ManifestPath, [DateTime]$Deadline) {
185
+ Remove-Item -LiteralPath $ManifestPath -Force -ErrorAction SilentlyContinue
186
+ $TimeoutSec = Get-SpeedProbeTimeoutSec $Deadline $SpeedProbeManifestTimeoutSeconds
187
+ if ($TimeoutSec -le 0 -or -not (Try-DownloadFile "$OssReleaseRoot/$Tag/release-download-manifest.tsv" $ManifestPath $TimeoutSec)) {
188
+ $TimeoutSec = Get-SpeedProbeTimeoutSec $Deadline $SpeedProbeManifestTimeoutSeconds
189
+ if ($TimeoutSec -le 0 -or -not (Try-DownloadFile "$GitHubReleaseRoot/$Tag/release-download-manifest.tsv" $ManifestPath $TimeoutSec)) {
190
+ return $null
191
+ }
192
+ }
193
+
194
+ $Lines = [IO.File]::ReadAllLines($ManifestPath, [Text.UTF8Encoding]::new($false))
195
+ if ($Lines.Count -lt 4) { return $null }
196
+ if ($Lines[0] -ne "penguin-release-download-manifest`t1`t$Tag") { return $null }
197
+
198
+ $SmallProbe = $null
199
+ $LargeProbe = $null
200
+ $AssetSize = 0L
201
+ foreach ($Line in $Lines | Select-Object -Skip 1) {
202
+ if (-not $Line) { return $null }
203
+ $Fields = $Line -split "`t"
204
+ if ($Fields[0] -eq "probe") {
205
+ if ($Fields.Count -ne 5) { return $null }
206
+ $Probe = [PSCustomObject]@{
207
+ Name = [string]$Fields[2]
208
+ Size = 0L
209
+ Sha256 = [string]$Fields[4]
210
+ }
211
+ if (-not (Test-SafeReleaseAssetName $Probe.Name)) { return $null }
212
+ $ProbeSize = 0L
213
+ if (-not [Int64]::TryParse([string]$Fields[3], [ref]$ProbeSize) -or $ProbeSize -le 0) { return $null }
214
+ $Probe.Size = $ProbeSize
215
+ if ($Probe.Sha256 -notmatch '^[0-9a-f]{64}$') { return $null }
216
+ if ($Fields[1] -eq "small") { $SmallProbe = $Probe }
217
+ if ($Fields[1] -eq "large") { $LargeProbe = $Probe }
218
+ } elseif ($Fields[0] -eq "asset" -and $Fields.Count -eq 4 -and $Fields[1] -eq $Asset) {
219
+ # Not part of the decision — an integrity check that this manifest belongs to a release which
220
+ # actually carries this target's bundle, so a mismatched manifest cannot steer the probe.
221
+ if (-not [Int64]::TryParse([string]$Fields[2], [ref]$AssetSize) -or $AssetSize -le 0) { return $null }
222
+ }
223
+ }
224
+
225
+ if (-not $SmallProbe -or -not $LargeProbe -or $AssetSize -le 0) { return $null }
226
+ [PSCustomObject]@{
227
+ SmallProbe = $SmallProbe
228
+ LargeProbe = $LargeProbe
229
+ AssetSize = $AssetSize
230
+ }
231
+ }
232
+
233
+ function Invoke-ProbeDownload(
234
+ [string]$BaseUrl,
235
+ [object]$Probe,
236
+ [string]$Tmp,
237
+ [string]$Label,
238
+ [DateTime]$Deadline,
239
+ [int]$MaxTimeoutSec = $SpeedProbeSmallTimeoutSeconds
240
+ ) {
241
+ $ProbePath = Join-Path $Tmp "probe-$Label-$($Probe.Name)"
242
+ Remove-Item -LiteralPath $ProbePath -Force -ErrorAction SilentlyContinue
243
+ $TimeoutSec = Get-SpeedProbeTimeoutSec $Deadline $MaxTimeoutSec
244
+ if ($TimeoutSec -le 0) {
245
+ return [PSCustomObject]@{ Ok = $false; Seconds = 0.0 }
246
+ }
247
+ $Succeeded = $false
248
+ $Elapsed = Measure-Command {
249
+ $Succeeded = Try-DownloadFile "$BaseUrl/$($Probe.Name)" $ProbePath $TimeoutSec
250
+ }
251
+ if (-not $Succeeded) {
252
+ return [PSCustomObject]@{ Ok = $false; Seconds = 0.0 }
253
+ }
254
+ try {
255
+ $Item = Get-Item -LiteralPath $ProbePath -ErrorAction Stop
256
+ if ($Item.Length -ne $Probe.Size) {
257
+ return [PSCustomObject]@{ Ok = $false; Seconds = 0.0 }
258
+ }
259
+ $ActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ProbePath).Hash.ToLowerInvariant()
260
+ if ($ActualHash -ne $Probe.Sha256) {
261
+ return [PSCustomObject]@{ Ok = $false; Seconds = 0.0 }
262
+ }
263
+ } catch {
264
+ return [PSCustomObject]@{ Ok = $false; Seconds = 0.0 }
265
+ }
266
+ $Seconds = [Math]::Max($Elapsed.TotalSeconds, 0.001)
267
+ [PSCustomObject]@{ Ok = $true; Seconds = $Seconds }
268
+ }
269
+
270
+ # Throughput a completed probe measured, in bytes per second; 0 when it did not complete, which
271
+ # sorts it below any real measurement in the comparison below.
272
+ function Get-ProbeBytesPerSecond([object]$Probe, [Int64]$ProbeSize) {
273
+ if (-not $Probe.Ok) { return 0.0 }
274
+ return [double]$ProbeSize / [Math]::Max([double]$Probe.Seconds, 0.001)
275
+ }
276
+
277
+ # The shared rule, in one place: GitHub clears the minimum and wins outright, otherwise the mirror
278
+ # has to beat it by the switch ratio to take over.
279
+ function Select-SpeedProbeSource([double]$GitHubBytesPerSecond, [double]$OssBytesPerSecond) {
280
+ if ($GitHubBytesPerSecond -ge $SpeedProbeGitHubMinBytesPerSecond) { return "github" }
281
+ if ($OssBytesPerSecond -gt ($GitHubBytesPerSecond * $SpeedProbeOssSwitchRatio)) { return "oss" }
282
+ return "github"
283
+ }
284
+
285
+ function Select-SpeedProbeDownloadSources([string]$Tag, [string]$Tmp) {
286
+ $Deadline = [DateTime]::UtcNow.AddSeconds($SpeedProbeTotalTimeoutSeconds)
287
+ $Manifest = Read-ReleaseDownloadManifest $Tag (Join-Path $Tmp "release-download-manifest.tsv") $Deadline
288
+ if (-not $Manifest) { return $null }
289
+
290
+ Write-Host "Testing OSS mirror and GitHub download sources ..."
291
+ $OssBase = "$OssReleaseRoot/$Tag"
292
+ $GitHubBase = "$GitHubReleaseRoot/$Tag"
293
+ $OssSmall = Invoke-ProbeDownload $OssBase $Manifest.SmallProbe $Tmp "oss-small" $Deadline
294
+ $GitHubSmall = Invoke-ProbeDownload $GitHubBase $Manifest.SmallProbe $Tmp "github-small" $Deadline
295
+
296
+ if ($GitHubSmall.Ok -and -not $OssSmall.Ok) {
297
+ Write-Host "Selected GitHub (OSS mirror probe unavailable)."
298
+ return [PSCustomObject]@{ BaseUrl = $GitHubBase; FallbackBaseUrl = $OssBase }
299
+ }
300
+ if ($OssSmall.Ok -and -not $GitHubSmall.Ok) {
301
+ Write-Host "Selected OSS mirror (GitHub probe unavailable)."
302
+ return [PSCustomObject]@{ BaseUrl = $OssBase; FallbackBaseUrl = $GitHubBase }
303
+ }
304
+ if (-not $OssSmall.Ok -and -not $GitHubSmall.Ok) { return $null }
305
+
306
+ $GitHubLarge = Invoke-ProbeDownload $GitHubBase $Manifest.LargeProbe $Tmp "github-large" $Deadline $SpeedProbeLargeTimeoutSeconds
307
+ $GitHubBytesPerSecond = Get-ProbeBytesPerSecond $GitHubLarge $Manifest.LargeProbe.Size
308
+
309
+ # OSS is measured only once GitHub has failed the minimum: above it GitHub has already won, and
310
+ # the mirror's bandwidth is not spent on a probe that could not change the answer. Probing runs
311
+ # one source at a time on purpose — two concurrent transfers share the link and would each read
312
+ # as half as fast, which an absolute threshold cannot tolerate.
313
+ $OssBytesPerSecond = 0.0
314
+ if ($GitHubBytesPerSecond -lt $SpeedProbeGitHubMinBytesPerSecond) {
315
+ $OssLarge = Invoke-ProbeDownload $OssBase $Manifest.LargeProbe $Tmp "oss-large" $Deadline $SpeedProbeLargeTimeoutSeconds
316
+ $OssBytesPerSecond = Get-ProbeBytesPerSecond $OssLarge $Manifest.LargeProbe.Size
317
+ }
318
+
319
+ if ((Select-SpeedProbeSource $GitHubBytesPerSecond $OssBytesPerSecond) -eq "github") {
320
+ if ($GitHubBytesPerSecond -ge $SpeedProbeGitHubMinBytesPerSecond) {
321
+ Write-Host "Selected GitHub (meets minimum download speed)."
322
+ } else {
323
+ Write-Host "Selected GitHub (the OSS mirror was not enough faster to be worth switching)."
324
+ }
325
+ return [PSCustomObject]@{ BaseUrl = $GitHubBase; FallbackBaseUrl = $OssBase }
326
+ }
327
+ Write-Host "Selected OSS mirror (clearly faster than GitHub here)."
328
+ return [PSCustomObject]@{ BaseUrl = $OssBase; FallbackBaseUrl = $GitHubBase }
329
+ }
330
+
331
+ function Restore-PreviousInstall(
332
+ [string]$InstallDir,
333
+ [string]$OldDir,
334
+ [string[]]$MovedOld,
335
+ [string[]]$MovedNew
336
+ ) {
337
+ foreach ($d in @($MovedNew)) {
338
+ if (-not $d) { continue }
339
+ $Current = Join-Path $InstallDir $d
340
+ if (Test-Path -LiteralPath $Current) {
341
+ Remove-Item -LiteralPath $Current -Recurse -Force -ErrorAction Stop
342
+ }
343
+ }
344
+ foreach ($d in @($MovedOld)) {
345
+ if (-not $d) { continue }
346
+ $Previous = Join-Path $OldDir $d
347
+ if (Test-Path -LiteralPath $Previous) {
348
+ Move-Item -LiteralPath $Previous -Destination (Join-Path $InstallDir $d) -ErrorAction Stop
349
+ }
350
+ }
351
+ }
352
+
353
+ # --- Resolve options (parameters win over env vars, mirroring install.sh's --version) ---
354
+ if (-not $Version) { $Version = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } }
355
+ if (-not $InstallDir) {
356
+ $InstallDir = if ($env:PENGUIN_INSTALL_DIR) { $env:PENGUIN_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".penguin" }
357
+ }
358
+ if (-not $ArchivePath) {
359
+ $ArchivePath = if ($env:PENGUIN_ARCHIVE) { $env:PENGUIN_ARCHIVE } else { "" }
360
+ }
361
+ $DownloadBaseUrl = if ($env:PENGUIN_DOWNLOAD_BASE_URL) {
362
+ $env:PENGUIN_DOWNLOAD_BASE_URL.TrimEnd('/')
363
+ } else {
364
+ ""
365
+ }
366
+ $DownloadFallbackBaseUrl = if ($env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL) {
367
+ $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL.TrimEnd('/')
368
+ } else {
369
+ ""
370
+ }
371
+ if (-not $DownloadBaseUrl) { $DownloadFallbackBaseUrl = "" }
372
+ $SourceMode = if ($env:PENGUIN_DOWNLOAD_SOURCE) {
373
+ $env:PENGUIN_DOWNLOAD_SOURCE.ToLowerInvariant()
374
+ } else {
375
+ "auto"
376
+ }
377
+ $DownloadSpeedProbe = if ($env:PENGUIN_DOWNLOAD_SPEED_PROBE) {
378
+ $env:PENGUIN_DOWNLOAD_SPEED_PROBE
379
+ } else {
380
+ "1"
381
+ }
382
+ # An extracted installer bundle keeps install.cmd, this script, payload.zip and its checksum
383
+ # together. `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs
384
+ # do not accidentally pick up an unrelated archive from the caller's current directory.
385
+ if (-not $ArchivePath -and $PSScriptRoot) {
386
+ $SiblingArchive = Join-Path $PSScriptRoot $PayloadName
387
+ if (Test-Path -LiteralPath $SiblingArchive -PathType Leaf) { $ArchivePath = $SiblingArchive }
388
+ }
389
+ if ($ArchivePath -and $Version) {
390
+ Fail "-ArchivePath/PENGUIN_ARCHIVE cannot be combined with -Version/PENGUIN_VERSION"
391
+ }
392
+ if ($Version -and -not (Test-ReleaseTag $Version)) {
393
+ Fail "invalid release version: $Version"
394
+ }
395
+ if ($SourceMode -notin @("auto", "oss", "github")) {
396
+ Fail "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github"
397
+ }
398
+ if ($DownloadSpeedProbe -notin @("0", "1")) {
399
+ Fail "PENGUIN_DOWNLOAD_SPEED_PROBE must be 0 or 1"
400
+ }
401
+ $ResolvedReleaseVersion = if ($Version) {
402
+ $Version
403
+ } elseif (Test-ReleaseTag $EmbeddedReleaseVersion) {
404
+ $EmbeddedReleaseVersion
405
+ } else {
406
+ ""
407
+ }
408
+ if ($DownloadBaseUrl) {
409
+ Assert-HttpsUrl "PENGUIN_DOWNLOAD_BASE_URL" $DownloadBaseUrl
410
+ }
411
+ if ($DownloadBaseUrl -and $DownloadFallbackBaseUrl) {
412
+ Assert-HttpsUrl "PENGUIN_DOWNLOAD_FALLBACK_BASE_URL" $DownloadFallbackBaseUrl
413
+ }
414
+
415
+ # --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) ---
416
+ if (-not [Environment]::Is64BitOperatingSystem) {
417
+ Fail "32-bit Windows is not supported. Install Node.js >= 24 and use: npm install -g @prismshadow/penguin-cli"
418
+ }
419
+ if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
420
+ Write-Host "note: no native ARM64 package yet; installing the x64 package (runs via emulation)."
421
+ }
422
+
423
+ # PowerShell 5.1 defaults to TLS 1.0 on older systems; GitHub requires TLS 1.2+.
424
+ try {
425
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
426
+ } catch {
427
+ # .NET builds where the enum is immutable already default to TLS 1.2+.
428
+ }
429
+
430
+ # --- Download. Explicit forwarder/configured URLs win. Otherwise a stamped Release installer
431
+ # uses its own immutable version: auto prefers OSS and falls back only to the same GitHub
432
+ # tag. An unstamped source-tree installer resolves latest.json first so it also locks one
433
+ # version before downloading assets. ---
434
+ $Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID"
435
+ if (Test-Path $Tmp) { Remove-Item -Recurse -Force $Tmp }
436
+ New-Item -ItemType Directory -Path $Tmp | Out-Null
437
+ # Pre-declare so the finally block can read them even when an early failure skipped the
438
+ # assignments (the user's session may run this under Set-StrictMode via the forwarder).
439
+ $Staging = $null
440
+ $OldDir = $null
441
+
442
+ try {
443
+ $UsingLocalArchive = [bool]$ArchivePath
444
+ if ($UsingLocalArchive) {
445
+ try {
446
+ $ZipPath = (Resolve-Path -LiteralPath $ArchivePath -ErrorAction Stop).Path
447
+ } catch {
448
+ Fail "local archive not found: $ArchivePath"
449
+ }
450
+ $ArchiveName = [IO.Path]::GetFileName($ZipPath)
451
+ Write-Host "Using local archive $ZipPath ..."
452
+ } else {
453
+ $BaseUrl = ""
454
+ $FallbackBaseUrl = $DownloadFallbackBaseUrl
455
+ if ($DownloadBaseUrl) {
456
+ $BaseUrl = $DownloadBaseUrl
457
+ } elseif ($SourceMode -eq "github") {
458
+ $BaseUrl = if ($ResolvedReleaseVersion) {
459
+ "$GitHubReleaseRoot/$ResolvedReleaseVersion"
460
+ } else {
461
+ $GitHubLatestBase
462
+ }
463
+ } else {
464
+ $SelectedTag = $ResolvedReleaseVersion
465
+ if (-not $SelectedTag) {
466
+ $SelectedTag = Get-OssLatestTag (Join-Path $Tmp "latest.json")
467
+ }
468
+ if ($SelectedTag) {
469
+ $BaseUrl = "$OssReleaseRoot/$SelectedTag"
470
+ if ($SourceMode -eq "auto" -and -not $FallbackBaseUrl) {
471
+ $FallbackBaseUrl = "$GitHubReleaseRoot/$SelectedTag"
472
+ }
473
+ if ($SourceMode -eq "auto" -and $DownloadSpeedProbe -eq "1" -and -not $DownloadBaseUrl) {
474
+ $SpeedProbeSources = Select-SpeedProbeDownloadSources $SelectedTag $Tmp
475
+ if ($SpeedProbeSources) {
476
+ $BaseUrl = $SpeedProbeSources.BaseUrl
477
+ $FallbackBaseUrl = $SpeedProbeSources.FallbackBaseUrl
478
+ } else {
479
+ Write-Host "Download source test was inconclusive; using OSS with same-version GitHub fallback."
480
+ }
481
+ }
482
+ } elseif ($SourceMode -eq "oss") {
483
+ Fail "the OSS mirror is unavailable or its release metadata is invalid."
484
+ } else {
485
+ $BaseUrl = $GitHubLatestBase
486
+ }
487
+ }
488
+
489
+ # Online: download the canonical bundle; the published checksum is mandatory.
490
+ $ZipPath = Join-Path $Tmp $Asset
491
+ $ArchiveName = $Asset
492
+ $ShaPath = Join-Path $Tmp "$Asset.sha256"
493
+ if (-not (Get-ReleasePair $BaseUrl $ZipPath $ShaPath)) {
494
+ if ($FallbackBaseUrl -and $FallbackBaseUrl -ne $BaseUrl) {
495
+ Write-Host "Primary download source unavailable; trying $(Get-DownloadSourceLabel $FallbackBaseUrl) ..."
496
+ if (-not (Get-ReleasePair $FallbackBaseUrl $ZipPath $ShaPath)) {
497
+ Fail "download failed from both the primary source and its fallback. Check your network, then retry."
498
+ }
499
+ } else {
500
+ Fail "download failed from $(Get-DownloadSourceLabel $BaseUrl). Check the version tag and your network, then retry."
501
+ }
502
+ }
503
+ Assert-Sha256 $ZipPath $ShaPath "Bundle"
504
+ }
505
+
506
+ # --- Resolve the program payload. An installer bundle (contains payload.zip) is opened flat
507
+ # and its sealed payload checksum verified; a program archive (payload.zip itself, or a
508
+ # pre-0.1.6 release zip with a top-level penguin\) is used directly. ---
509
+ $ArchiveShape = if ((Get-ZipEntryNames $ZipPath) -contains $PayloadName) { "bundle" } else { "program" }
510
+ if ($ArchiveShape -eq "bundle") {
511
+ # A local bundle is self-verifying through its sealed payload checksum; when the published
512
+ # outer .sha256 was transferred alongside it, verify that layer too.
513
+ if ($UsingLocalArchive -and (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
514
+ Assert-Sha256 $ZipPath "$ZipPath.sha256" "Bundle"
515
+ }
516
+ $BundleDir = Join-Path $Tmp "bundle"
517
+ New-Item -ItemType Directory -Path $BundleDir | Out-Null
518
+ Write-Host "Opening installer bundle ..."
519
+ # The outer layer is flat (four files), so this extraction never creates deep paths.
520
+ Expand-Archive -LiteralPath $ZipPath -DestinationPath $BundleDir -Force
521
+ $ZipPath = Join-Path $BundleDir $PayloadName
522
+ if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) {
523
+ Fail "unexpected bundle layout: $PayloadName missing."
524
+ }
525
+ if (-not (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
526
+ Fail "unexpected bundle layout: $PayloadName.sha256 missing."
527
+ }
528
+ Assert-Sha256 $ZipPath "$ZipPath.sha256" "Payload"
529
+ } elseif ($UsingLocalArchive) {
530
+ # Program archive from disk: an adjacent checksum is required — its own, or the canonical
531
+ # asset checksum next to a renamed legacy file. (An online download was already verified
532
+ # against the published .sha256 above.)
533
+ $ShaPath = "$ZipPath.sha256"
534
+ if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf) -and
535
+ $ArchiveName -ine $Asset) {
536
+ $CanonicalShaPath = Join-Path ([IO.Path]::GetDirectoryName($ZipPath)) "$Asset.sha256"
537
+ if (Test-Path -LiteralPath $CanonicalShaPath -PathType Leaf) {
538
+ $ShaPath = $CanonicalShaPath
539
+ }
540
+ }
541
+ if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf)) {
542
+ Fail "offline checksum file not found: $ShaPath"
543
+ }
544
+ Assert-Sha256 $ZipPath $ShaPath "Payload"
545
+ }
546
+
547
+ # --- Extract into staging, then swap by same-volume renames. Keep the previous dirs in .old.$PID
548
+ # until the installed command runs successfully; any move or launch failure restores them.
549
+ # The data dir (%USERPROFILE%\.penguin\data) is never part of the swap. ---
550
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
551
+ $Staging = Join-Path $InstallDir ".staging.$PID"
552
+ $OldDir = Join-Path $InstallDir ".old.$PID"
553
+ if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
554
+ if (Test-Path $OldDir) { Remove-Item -Recurse -Force $OldDir }
555
+ New-Item -ItemType Directory -Path $Staging | Out-Null
556
+
557
+ Write-Host "Extracting ..."
558
+ Expand-Archive -LiteralPath $ZipPath -DestinationPath $Staging -Force
559
+ $NewRoot = Join-Path $Staging "penguin"
560
+ if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." }
561
+ if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." }
562
+ $ManifestPath = Join-Path $NewRoot "package-manifest.json"
563
+ if (Test-Path -LiteralPath $ManifestPath -PathType Leaf) {
564
+ try {
565
+ $PackageManifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
566
+ } catch {
567
+ Fail "package manifest is malformed: $($_.Exception.Message)"
568
+ }
569
+ $TargetProperty = $PackageManifest.PSObject.Properties["target"]
570
+ if ($null -eq $TargetProperty -or -not $TargetProperty.Value) {
571
+ Fail "package manifest is malformed: target missing."
572
+ }
573
+ if ([string]$TargetProperty.Value -ine "win32-x64") {
574
+ Fail "package target mismatch: expected win32-x64, found $($TargetProperty.Value)."
575
+ }
576
+ } elseif ($UsingLocalArchive -and $ArchiveShape -eq "program" -and
577
+ $ArchiveName -ine $Asset -and $ArchiveName -ine $PayloadName) {
578
+ Fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
579
+ }
580
+
581
+ $Dirs = @("bin", "lib", "web", "node", "git")
582
+ $MovedOld = @()
583
+ $MovedNew = @()
584
+ New-Item -ItemType Directory -Path $OldDir | Out-Null
585
+ try {
586
+ foreach ($d in $Dirs) {
587
+ $Existing = Join-Path $InstallDir $d
588
+ if (Test-Path -LiteralPath $Existing) {
589
+ Move-Item -LiteralPath $Existing -Destination (Join-Path $OldDir $d)
590
+ $MovedOld += $d
591
+ }
592
+ }
593
+ foreach ($d in $Dirs) {
594
+ $Src = Join-Path $NewRoot $d
595
+ if (Test-Path -LiteralPath $Src) {
596
+ Move-Item -LiteralPath $Src -Destination (Join-Path $InstallDir $d)
597
+ $MovedNew += $d
598
+ }
599
+ }
600
+
601
+ # --- Launcher shim: shipped in the payload; (re)generated only when missing. There is
602
+ # deliberately no penguin.ps1 launcher — PowerShell would prefer it over penguin.cmd on
603
+ # PATH, and client Windows defaults to the Restricted execution policy, so a .ps1
604
+ # launcher makes the plain `penguin` command fail with "running scripts is disabled".
605
+ # Batch files are policy-exempt and both PowerShell and cmd.exe resolve penguin.cmd.
606
+ # (bin\ is swapped wholesale above, so upgrading also removes the penguin.ps1 that
607
+ # pre-0.1.6 payloads shipped.) ---
608
+ $CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
609
+ if (-not (Test-Path -LiteralPath $CmdShim)) {
610
+ @(
611
+ '@echo off'
612
+ 'setlocal'
613
+ 'set "DIR=%~dp0.."'
614
+ 'if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"'
615
+ 'if exist "%DIR%\git\usr\bin\sh.exe" set "PENGUIN_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe"'
616
+ 'if exist "%DIR%\node\node.exe" ('
617
+ ' "%DIR%\node\node.exe" "%DIR%\lib\dist\penguin.js" %*'
618
+ ') else ('
619
+ ' node "%DIR%\lib\dist\penguin.js" %*'
620
+ ')'
621
+ 'exit /b %ERRORLEVEL%'
622
+ ) | Set-Content -LiteralPath $CmdShim -Encoding ascii
623
+ }
624
+ if (-not (Test-Path -LiteralPath $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
625
+
626
+ # Verify from the final path before deleting the backup. Keep stderr visible so platform
627
+ # policy, permission and runtime errors are not disguised as an "unknown" version.
628
+ # Windows PowerShell 5.1 turns native stderr into NativeCommandError records. Temporarily
629
+ # keep those non-terminating so the real stderr stays visible and the exit code remains the
630
+ # authoritative result; restore Stop immediately afterwards for installer operations.
631
+ $PreviousErrorActionPreference = $ErrorActionPreference
632
+ try {
633
+ $ErrorActionPreference = "Continue"
634
+ $VersionOutput = @(& $CmdShim --version)
635
+ $VersionExitCode = $LASTEXITCODE
636
+ } finally {
637
+ $ErrorActionPreference = $PreviousErrorActionPreference
638
+ }
639
+ if ($VersionExitCode -ne 0) {
640
+ Fail "installed PenguinHarness failed to run (exit code $VersionExitCode). See the error above."
641
+ }
642
+ $InstalledVersion = $VersionOutput | Select-Object -First 1
643
+ if ([string]::IsNullOrWhiteSpace([string]$InstalledVersion)) {
644
+ Fail "installed PenguinHarness returned an empty version."
645
+ }
646
+ } catch {
647
+ $InstallFailure = $_
648
+ try {
649
+ Restore-PreviousInstall -InstallDir $InstallDir -OldDir $OldDir -MovedOld $MovedOld -MovedNew $MovedNew
650
+ Write-Host "Previous PenguinHarness installation restored."
651
+ } catch {
652
+ throw "error: installation failed and automatic rollback was incomplete. Original error: $($InstallFailure.Exception.Message) Rollback error: $($_.Exception.Message) Previous files may remain in $OldDir"
653
+ }
654
+ throw $InstallFailure
655
+ }
656
+
657
+ Remove-Item -LiteralPath $OldDir -Recurse -Force -ErrorAction SilentlyContinue
658
+ if (Test-Path -LiteralPath $OldDir) {
659
+ Write-Host "warning: could not fully remove $OldDir; delete it after closing running penguin processes."
660
+ }
661
+ } finally {
662
+ Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
663
+ if ($Staging -and (Test-Path -LiteralPath $Staging)) {
664
+ Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
665
+ }
666
+ }
667
+
668
+ # --- User PATH: append <install>\bin once only after the installed command is known to work.
669
+ # Go through the registry, not [Environment]::*EnvironmentVariable: GetEnvironmentVariable
670
+ # expands REG_EXPAND_SZ and SetEnvironmentVariable writes back REG_SZ, which would
671
+ # irreversibly hard-code a user's %USERPROFILE%-style Path entries. Read the raw
672
+ # (unexpanded) value, append to it, and write it back with its original value kind.
673
+ # The registry only exists on Windows; skip the block elsewhere (functional test runs
674
+ # of this script on pwsh/Linux — where the old API was a silent no-op anyway). ---
675
+ $BinDir = Join-Path $InstallDir "bin"
676
+ $PathUpdateMessage = ""
677
+ if ($env:OS -eq "Windows_NT") {
678
+ $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true)
679
+ if ($null -eq $EnvKey) { $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") }
680
+ try {
681
+ # Missing Path value: create it as REG_EXPAND_SZ (the kind Windows itself uses for Path).
682
+ $Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
683
+ try { $Kind = $EnvKey.GetValueKind("Path") } catch {}
684
+ $RawPath = [string]$EnvKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
685
+ # Membership is checked per entry after expansion, so both literal and %VAR%-style
686
+ # spellings of the bin dir count as already present; the append itself stays raw.
687
+ $OnPath = @($RawPath -split ";" | Where-Object { $_ } | ForEach-Object {
688
+ [Environment]::ExpandEnvironmentVariables($_).TrimEnd("\")
689
+ }) -contains $BinDir.TrimEnd("\")
690
+ if (-not $OnPath) {
691
+ $NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" }
692
+ $EnvKey.SetValue("Path", $NewPath, $Kind)
693
+ # A raw registry write does not broadcast WM_SETTINGCHANGE, so Explorer — and every
694
+ # terminal window launched from it afterwards — would keep the stale Path until the next
695
+ # logon. Broadcast it the way [Environment]::SetEnvironmentVariable would; best-effort,
696
+ # a failure only means new terminals need a fresh logon to see the Path.
697
+ try {
698
+ if (-not ("PenguinInstaller.NativeMethods" -as [type])) {
699
+ Add-Type -Namespace PenguinInstaller -Name NativeMethods -MemberDefinition @'
700
+ [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
701
+ public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
702
+ '@
703
+ }
704
+ $BroadcastResult = [UIntPtr]::Zero
705
+ # HWND_BROADCAST (0xffff), WM_SETTINGCHANGE (0x1a), SMTO_ABORTIFHUNG (0x2), 5s timeout.
706
+ [PenguinInstaller.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero,
707
+ "Environment", 2, 5000, [ref]$BroadcastResult) | Out-Null
708
+ } catch {
709
+ }
710
+ $PathUpdateMessage = "note: installation succeeded and $BinDir was appended to your user Path. Open a new terminal window so 'penguin' is found (a new tab of an already-running terminal keeps the old Path)."
711
+ }
712
+ } finally {
713
+ $EnvKey.Close()
714
+ }
715
+ }
716
+ # Make `penguin` work in this session too.
717
+ if (($env:Path -split ";") -notcontains $BinDir) { $env:Path = "$env:Path;$BinDir" }
718
+
719
+ Write-Host ""
720
+ Write-Host "PenguinHarness $InstalledVersion installed to $InstallDir"
721
+ if ($PathUpdateMessage) {
722
+ Write-Host ""
723
+ Write-Host $PathUpdateMessage
724
+ }
725
+ Write-Host ""
726
+ Write-Host "Get started:"
727
+ Write-Host " penguin --help # all commands"
728
+ Write-Host " penguin web # start the Web UI at http://127.0.0.1:7364 (a first-login link is printed on first start)"
729
+ Write-Host " penguin server # headless server (PORT / HOST to override)"