@prismshadow/penguin-server 0.2.6 → 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.
- package/README.md +4 -4
- package/dist/api/types.d.ts +393 -21
- package/dist/auth-token.d.ts +29 -0
- package/dist/auth-token.js +51 -0
- package/dist/auth-token.js.map +1 -0
- package/dist/{chunk-WKJ3ANJP.js → chunk-622KL7BQ.js} +92 -381
- package/dist/chunk-622KL7BQ.js.map +1 -0
- package/dist/chunk-K4ZSUT72.js +34 -0
- package/dist/chunk-K4ZSUT72.js.map +1 -0
- package/dist/chunk-UGAFPYVX.js +374 -0
- package/dist/chunk-UGAFPYVX.js.map +1 -0
- package/dist/extension/index.d.ts +212 -0
- package/dist/extension/index.js +1 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/index.js +5758 -1310
- package/dist/index.js.map +1 -1
- package/dist/initial-password.d.ts +9 -12
- package/dist/initial-password.js +3 -7
- package/dist/install.ps1 +729 -0
- package/dist/install.sh +785 -0
- package/dist/reset-admin-password.d.ts +4 -7
- package/dist/reset-admin-password.js +11 -9
- package/dist/reset-admin-password.js.map +1 -1
- package/dist/secret-file.d.ts +3 -0
- package/dist/secret-file.js +17 -0
- package/dist/secret-file.js.map +1 -0
- package/package.json +19 -3
- package/web-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web-dist/assets/{addon-fit-CE-3eCvv.js → addon-fit-DHOzI3Ur.js} +1 -1
- package/web-dist/assets/{addon-web-links-DeODgawX.js → addon-web-links-DC7759H1.js} +1 -1
- package/web-dist/assets/{highlighter-DpnM5FfD.js → highlighter-CGPO6Q4b.js} +1 -1
- package/web-dist/assets/index-CaFDHl-w.js +451 -0
- package/web-dist/assets/index-D8Bk9LtJ.css +1 -0
- package/web-dist/assets/{xterm-BjU-ZsL5.js → xterm-DY5ko-BU.js} +1 -1
- package/web-dist/index.html +2 -2
- package/dist/chunk-UUJCF46V.js +0 -50
- package/dist/chunk-UUJCF46V.js.map +0 -1
- package/dist/chunk-WKJ3ANJP.js.map +0 -1
- package/web-dist/assets/index-aRsvImZt.css +0 -1
- package/web-dist/assets/index-gNzir_kH.js +0 -189
package/dist/install.ps1
ADDED
|
@@ -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)"
|