@adelie/server 0.5.10 → 0.6.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.
package/dist/install.ps1 CHANGED
@@ -1,492 +1,492 @@
1
- # Adelie one-line installer for Windows.
2
- #
3
- # irm https://github.com/lmliheng/Adelie/releases/latest/download/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\.adelie
9
- # $env:PENGUIN_ARCHIVE = "<file>" install a local Release zip without network access (same as -ArchivePath)
10
- # $env:PENGUIN_DOWNLOAD_SOURCE = "auto|github" choose the online source; default auto (GitHub Releases)
11
- # $env:PENGUIN_DOWNLOAD_BASE_URL = "https://..." exact online asset directory selected by the stable forwarder
12
- # $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = "https://..." fallback for PENGUIN_DOWNLOAD_BASE_URL
13
- # -NoModifyPath do not add <install>\bin to the user Path; for a second installation
14
- # beside the one the `penguin` command belongs to
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 @adelie/cli` instead.
27
- #
28
- # The data dir (%USERPROFILE%\.adelie\data by default) 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
- # The launcher exports the root it lives under, so an install made before the rename keeps its data.
31
- #
32
- # Docs: https://lmliheng.github.io/Adelie/docs/quickstart-cli
33
- param(
34
- [string]$Version = "",
35
- [string]$InstallDir = "",
36
- [string]$ArchivePath = "",
37
- [switch]$NoModifyPath
38
- )
39
-
40
- $ErrorActionPreference = "Stop"
41
- $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering slows downloads massively on PS 5.1
42
-
43
- $Repo = "https://github.com/lmliheng/Adelie"
44
- $GitHubReleaseRoot = "$Repo/releases/download"
45
- $GitHubLatestBase = "$Repo/releases/latest/download"
46
- $Asset = "penguin-win32-x64.zip"
47
- $PayloadName = "payload.zip"
48
- # The release workflow replaces this token with the immutable tag before publishing both the
49
- # standalone installer and the copy sealed inside the Windows bundle.
50
- $EmbeddedReleaseVersion = "__PENGUIN_RELEASE_VERSION__"
51
-
52
- function Fail([string]$Message) {
53
- # `throw` rather than `exit`: a caller that runs this installer as an in-memory script block
54
- # (rather than as a file) would otherwise have `exit` terminate its whole PowerShell session.
55
- # `throw` aborts cleanly in both file and script-block runs.
56
- throw "error: $Message"
57
- }
58
-
59
- # Lists a zip's top-level entry names without extracting (PS 5.1-safe; Expand-Archive itself
60
- # uses the same .NET type). Used to tell an installer bundle (contains payload.zip) from a
61
- # program archive (payload.zip itself, or a pre-0.1.6 release zip with a top-level penguin\).
62
- function Get-ZipEntryNames([string]$ZipPath) {
63
- Add-Type -AssemblyName System.IO.Compression.FileSystem
64
- $Zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
65
- try {
66
- return @($Zip.Entries | ForEach-Object { $_.FullName })
67
- } finally {
68
- $Zip.Dispose()
69
- }
70
- }
71
-
72
- # Verifies a file against a sha256sum-format checksum file (`<hex> <filename>`; the first
73
- # token is the hash). Checksums are never optional: every install path either downloads the
74
- # published .sha256 or reads the one sealed inside the bundle.
75
- function Assert-Sha256([string]$FilePath, [string]$ShaPath, [string]$Label) {
76
- $Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
77
- if (-not $Expected) { Fail "checksum file is empty or malformed: $ShaPath" }
78
- $Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $FilePath).Hash
79
- if ($Actual -ine $Expected) { Fail "checksum mismatch for $([IO.Path]::GetFileName($FilePath))." }
80
- Write-Host "$Label checksum OK."
81
- }
82
-
83
- function Assert-HttpsUrl([string]$Name, [string]$Value) {
84
- try { $Uri = [Uri]$Value } catch { Fail "$Name is not a valid URL" }
85
- if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -ne "https") {
86
- Fail "$Name must be an absolute HTTPS URL"
87
- }
88
- }
89
-
90
- function Test-ReleaseTag([string]$Value) {
91
- return $Value -match '^v[0-9A-Za-z][0-9A-Za-z._-]*$'
92
- }
93
-
94
- function Get-DownloadSourceLabel([string]$BaseUrl) {
95
- try { $HostName = ([Uri]$BaseUrl).Host } catch { return "configured mirror" }
96
- if ($HostName -eq "github.com") { return "GitHub" }
97
- return "configured mirror"
98
- }
99
-
100
- function Get-ReleasePair(
101
- [string]$BaseUrl,
102
- [string]$ZipPath,
103
- [string]$ShaPath
104
- ) {
105
- $Label = Get-DownloadSourceLabel $BaseUrl
106
- Write-Host "Downloading $Asset from $Label ..."
107
- Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue
108
- try {
109
- Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing
110
- Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
111
- return $true
112
- } catch {
113
- Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue
114
- return $false
115
- }
116
- }
117
-
118
- function Restore-PreviousInstall(
119
- [string]$InstallDir,
120
- [string]$OldDir,
121
- [string[]]$MovedOld,
122
- [string[]]$MovedNew
123
- ) {
124
- foreach ($d in @($MovedNew)) {
125
- if (-not $d) { continue }
126
- $Current = Join-Path $InstallDir $d
127
- if (Test-Path -LiteralPath $Current) {
128
- Remove-Item -LiteralPath $Current -Recurse -Force -ErrorAction Stop
129
- }
130
- }
131
- foreach ($d in @($MovedOld)) {
132
- if (-not $d) { continue }
133
- $Previous = Join-Path $OldDir $d
134
- if (Test-Path -LiteralPath $Previous) {
135
- Move-Item -LiteralPath $Previous -Destination (Join-Path $InstallDir $d) -ErrorAction Stop
136
- }
137
- }
138
- }
139
-
140
- # --- Resolve options (parameters win over env vars, mirroring install.sh's --version) ---
141
- if (-not $Version) { $Version = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } }
142
- if (-not $InstallDir) {
143
- $InstallDir = if ($env:PENGUIN_INSTALL_DIR) { $env:PENGUIN_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".adelie" }
144
- }
145
- if (-not $ArchivePath) {
146
- $ArchivePath = if ($env:PENGUIN_ARCHIVE) { $env:PENGUIN_ARCHIVE } else { "" }
147
- }
148
- $DownloadBaseUrl = if ($env:PENGUIN_DOWNLOAD_BASE_URL) {
149
- $env:PENGUIN_DOWNLOAD_BASE_URL.TrimEnd('/')
150
- } else {
151
- ""
152
- }
153
- $DownloadFallbackBaseUrl = if ($env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL) {
154
- $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL.TrimEnd('/')
155
- } else {
156
- ""
157
- }
158
- if (-not $DownloadBaseUrl) { $DownloadFallbackBaseUrl = "" }
159
- $SourceMode = if ($env:PENGUIN_DOWNLOAD_SOURCE) {
160
- $env:PENGUIN_DOWNLOAD_SOURCE.ToLowerInvariant()
161
- } else {
162
- "auto"
163
- }
164
- # An extracted installer bundle keeps install.cmd, this script, payload.zip and its checksum
165
- # together. `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs
166
- # do not accidentally pick up an unrelated archive from the caller's current directory.
167
- if (-not $ArchivePath -and $PSScriptRoot) {
168
- $SiblingArchive = Join-Path $PSScriptRoot $PayloadName
169
- if (Test-Path -LiteralPath $SiblingArchive -PathType Leaf) { $ArchivePath = $SiblingArchive }
170
- }
171
- if ($ArchivePath -and $Version) {
172
- Fail "-ArchivePath/PENGUIN_ARCHIVE cannot be combined with -Version/PENGUIN_VERSION"
173
- }
174
- if ($Version -and -not (Test-ReleaseTag $Version)) {
175
- Fail "invalid release version: $Version"
176
- }
177
- if ($SourceMode -notin @("auto", "github")) {
178
- Fail "PENGUIN_DOWNLOAD_SOURCE must be auto or github"
179
- }
180
- $ResolvedReleaseVersion = if ($Version) {
181
- $Version
182
- } elseif (Test-ReleaseTag $EmbeddedReleaseVersion) {
183
- $EmbeddedReleaseVersion
184
- } else {
185
- ""
186
- }
187
- if ($DownloadBaseUrl) {
188
- Assert-HttpsUrl "PENGUIN_DOWNLOAD_BASE_URL" $DownloadBaseUrl
189
- }
190
- if ($DownloadBaseUrl -and $DownloadFallbackBaseUrl) {
191
- Assert-HttpsUrl "PENGUIN_DOWNLOAD_FALLBACK_BASE_URL" $DownloadFallbackBaseUrl
192
- }
193
-
194
- # --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) ---
195
- if (-not [Environment]::Is64BitOperatingSystem) {
196
- Fail "32-bit Windows is not supported. Install Node.js >= 24 and use: npm install -g @adelie/cli"
197
- }
198
- if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
199
- Write-Host "note: no native ARM64 package yet; installing the x64 package (runs via emulation)."
200
- }
201
-
202
- # PowerShell 5.1 defaults to TLS 1.0 on older systems; GitHub requires TLS 1.2+.
203
- try {
204
- [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
205
- } catch {
206
- # .NET builds where the enum is immutable already default to TLS 1.2+.
207
- }
208
-
209
- # --- Download. An explicit forwarder/configured URL wins. Otherwise a stamped Release installer
210
- # uses its own immutable version; an unstamped source-tree installer falls back to the
211
- # release GitHub currently marks latest. ---
212
- $Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID"
213
- if (Test-Path $Tmp) { Remove-Item -Recurse -Force $Tmp }
214
- New-Item -ItemType Directory -Path $Tmp | Out-Null
215
- # Pre-declare so the finally block can read them even when an early failure skipped the
216
- # assignments (the user's session may run this under Set-StrictMode via the forwarder).
217
- $Staging = $null
218
- $OldDir = $null
219
-
220
- try {
221
- $UsingLocalArchive = [bool]$ArchivePath
222
- if ($UsingLocalArchive) {
223
- try {
224
- $ZipPath = (Resolve-Path -LiteralPath $ArchivePath -ErrorAction Stop).Path
225
- } catch {
226
- Fail "local archive not found: $ArchivePath"
227
- }
228
- $ArchiveName = [IO.Path]::GetFileName($ZipPath)
229
- Write-Host "Using local archive $ZipPath ..."
230
- } else {
231
- $BaseUrl = ""
232
- $FallbackBaseUrl = $DownloadFallbackBaseUrl
233
- if ($DownloadBaseUrl) {
234
- $BaseUrl = $DownloadBaseUrl
235
- } elseif ($ResolvedReleaseVersion) {
236
- $BaseUrl = "$GitHubReleaseRoot/$ResolvedReleaseVersion"
237
- } else {
238
- $BaseUrl = $GitHubLatestBase
239
- }
240
-
241
- # Online: download the canonical bundle; the published checksum is mandatory.
242
- $ZipPath = Join-Path $Tmp $Asset
243
- $ArchiveName = $Asset
244
- $ShaPath = Join-Path $Tmp "$Asset.sha256"
245
- if (-not (Get-ReleasePair $BaseUrl $ZipPath $ShaPath)) {
246
- if ($FallbackBaseUrl -and $FallbackBaseUrl -ne $BaseUrl) {
247
- Write-Host "Primary download source unavailable; trying $(Get-DownloadSourceLabel $FallbackBaseUrl) ..."
248
- if (-not (Get-ReleasePair $FallbackBaseUrl $ZipPath $ShaPath)) {
249
- Fail "download failed from both the primary source and its fallback. Check your network, then retry."
250
- }
251
- } else {
252
- Fail "download failed from $(Get-DownloadSourceLabel $BaseUrl). Check the version tag and your network, then retry."
253
- }
254
- }
255
- Assert-Sha256 $ZipPath $ShaPath "Bundle"
256
- }
257
-
258
- # --- Resolve the program payload. An installer bundle (contains payload.zip) is opened flat
259
- # and its sealed payload checksum verified; a program archive (payload.zip itself, or a
260
- # pre-0.1.6 release zip with a top-level penguin\) is used directly. ---
261
- $ArchiveShape = if ((Get-ZipEntryNames $ZipPath) -contains $PayloadName) { "bundle" } else { "program" }
262
- if ($ArchiveShape -eq "bundle") {
263
- # A local bundle is self-verifying through its sealed payload checksum; when the published
264
- # outer .sha256 was transferred alongside it, verify that layer too.
265
- if ($UsingLocalArchive -and (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
266
- Assert-Sha256 $ZipPath "$ZipPath.sha256" "Bundle"
267
- }
268
- $BundleDir = Join-Path $Tmp "bundle"
269
- New-Item -ItemType Directory -Path $BundleDir | Out-Null
270
- Write-Host "Opening installer bundle ..."
271
- # The outer layer is flat (four files), so this extraction never creates deep paths.
272
- Expand-Archive -LiteralPath $ZipPath -DestinationPath $BundleDir -Force
273
- $ZipPath = Join-Path $BundleDir $PayloadName
274
- if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) {
275
- Fail "unexpected bundle layout: $PayloadName missing."
276
- }
277
- if (-not (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
278
- Fail "unexpected bundle layout: $PayloadName.sha256 missing."
279
- }
280
- Assert-Sha256 $ZipPath "$ZipPath.sha256" "Payload"
281
- } elseif ($UsingLocalArchive) {
282
- # Program archive from disk: an adjacent checksum is required — its own, or the canonical
283
- # asset checksum next to a renamed legacy file. (An online download was already verified
284
- # against the published .sha256 above.)
285
- $ShaPath = "$ZipPath.sha256"
286
- if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf) -and
287
- $ArchiveName -ine $Asset) {
288
- $CanonicalShaPath = Join-Path ([IO.Path]::GetDirectoryName($ZipPath)) "$Asset.sha256"
289
- if (Test-Path -LiteralPath $CanonicalShaPath -PathType Leaf) {
290
- $ShaPath = $CanonicalShaPath
291
- }
292
- }
293
- if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf)) {
294
- Fail "offline checksum file not found: $ShaPath"
295
- }
296
- Assert-Sha256 $ZipPath $ShaPath "Payload"
297
- }
298
-
299
- # --- Extract into staging, then swap by same-volume renames. Keep the previous dirs in .old.$PID
300
- # until the installed command runs successfully; any move or launch failure restores them.
301
- # The data dir (%USERPROFILE%\.adelie\data by default) is never part of the swap. ---
302
- New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
303
- $Staging = Join-Path $InstallDir ".staging.$PID"
304
- $OldDir = Join-Path $InstallDir ".old.$PID"
305
- if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
306
- if (Test-Path $OldDir) { Remove-Item -Recurse -Force $OldDir }
307
- New-Item -ItemType Directory -Path $Staging | Out-Null
308
-
309
- Write-Host "Extracting ..."
310
- Expand-Archive -LiteralPath $ZipPath -DestinationPath $Staging -Force
311
- $NewRoot = Join-Path $Staging "penguin"
312
- if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." }
313
- if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." }
314
- $ManifestPath = Join-Path $NewRoot "package-manifest.json"
315
- if (Test-Path -LiteralPath $ManifestPath -PathType Leaf) {
316
- try {
317
- $PackageManifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
318
- } catch {
319
- Fail "package manifest is malformed: $($_.Exception.Message)"
320
- }
321
- $TargetProperty = $PackageManifest.PSObject.Properties["target"]
322
- if ($null -eq $TargetProperty -or -not $TargetProperty.Value) {
323
- Fail "package manifest is malformed: target missing."
324
- }
325
- if ([string]$TargetProperty.Value -ine "win32-x64") {
326
- Fail "package target mismatch: expected win32-x64, found $($TargetProperty.Value)."
327
- }
328
- } elseif ($UsingLocalArchive -and $ArchiveShape -eq "program" -and
329
- $ArchiveName -ine $Asset -and $ArchiveName -ine $PayloadName) {
330
- Fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
331
- }
332
-
333
- $Dirs = @("bin", "lib", "web", "node", "git")
334
- $MovedOld = @()
335
- $MovedNew = @()
336
- New-Item -ItemType Directory -Path $OldDir | Out-Null
337
- try {
338
- foreach ($d in $Dirs) {
339
- $Existing = Join-Path $InstallDir $d
340
- if (Test-Path -LiteralPath $Existing) {
341
- Move-Item -LiteralPath $Existing -Destination (Join-Path $OldDir $d)
342
- $MovedOld += $d
343
- }
344
- }
345
- foreach ($d in $Dirs) {
346
- $Src = Join-Path $NewRoot $d
347
- if (Test-Path -LiteralPath $Src) {
348
- Move-Item -LiteralPath $Src -Destination (Join-Path $InstallDir $d)
349
- $MovedNew += $d
350
- }
351
- }
352
-
353
- # --- Launcher shim: shipped in the payload; (re)generated only when missing. The two
354
- # variables it fills in use Adelie's spelling, with the pre-rename PENGUIN_WEB_DIST
355
- # still honored, exactly like scripts/launchers/penguin.cmd — a shim regenerated here
356
- # must not behave differently from the one the payload ships. There is
357
- # deliberately no penguin.ps1 launcher — PowerShell would prefer it over penguin.cmd on
358
- # PATH, and client Windows defaults to the Restricted execution policy, so a .ps1
359
- # launcher makes the plain `penguin` command fail with "running scripts is disabled".
360
- # Batch files are policy-exempt and both PowerShell and cmd.exe resolve penguin.cmd.
361
- # (bin\ is swapped wholesale above, so upgrading also removes the penguin.ps1 that
362
- # pre-0.1.6 payloads shipped.) ---
363
- $CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
364
- if (-not (Test-Path -LiteralPath $CmdShim)) {
365
- @(
366
- '@echo off'
367
- 'setlocal'
368
- 'set "DIR=%~dp0.."'
369
- 'if not defined ADELIE_WEB_DIST if defined PENGUIN_WEB_DIST set "ADELIE_WEB_DIST=%PENGUIN_WEB_DIST%"'
370
- 'if not defined ADELIE_WEB_DIST set "ADELIE_WEB_DIST=%DIR%\web"'
371
- 'if exist "%DIR%\git\usr\bin\sh.exe" set "ADELIE_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe"'
372
- 'if exist "%DIR%\node\node.exe" ('
373
- ' "%DIR%\node\node.exe" "%DIR%\lib\dist\penguin.js" %*'
374
- ') else ('
375
- ' node "%DIR%\lib\dist\penguin.js" %*'
376
- ')'
377
- 'exit /b %ERRORLEVEL%'
378
- ) | Set-Content -LiteralPath $CmdShim -Encoding ascii
379
- }
380
- if (-not (Test-Path -LiteralPath $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
381
-
382
- # Verify from the final path before deleting the backup. Keep stderr visible so platform
383
- # policy, permission and runtime errors are not disguised as an "unknown" version.
384
- # Windows PowerShell 5.1 turns native stderr into NativeCommandError records. Temporarily
385
- # keep those non-terminating so the real stderr stays visible and the exit code remains the
386
- # authoritative result; restore Stop immediately afterwards for installer operations.
387
- $PreviousErrorActionPreference = $ErrorActionPreference
388
- try {
389
- $ErrorActionPreference = "Continue"
390
- $VersionOutput = @(& $CmdShim --version)
391
- $VersionExitCode = $LASTEXITCODE
392
- } finally {
393
- $ErrorActionPreference = $PreviousErrorActionPreference
394
- }
395
- if ($VersionExitCode -ne 0) {
396
- Fail "installed Adelie failed to run (exit code $VersionExitCode). See the error above."
397
- }
398
- $InstalledVersion = $VersionOutput | Select-Object -First 1
399
- if ([string]::IsNullOrWhiteSpace([string]$InstalledVersion)) {
400
- Fail "installed Adelie returned an empty version."
401
- }
402
- } catch {
403
- $InstallFailure = $_
404
- try {
405
- Restore-PreviousInstall -InstallDir $InstallDir -OldDir $OldDir -MovedOld $MovedOld -MovedNew $MovedNew
406
- Write-Host "Previous Adelie installation restored."
407
- } catch {
408
- throw "error: installation failed and automatic rollback was incomplete. Original error: $($InstallFailure.Exception.Message) Rollback error: $($_.Exception.Message) Previous files may remain in $OldDir"
409
- }
410
- throw $InstallFailure
411
- }
412
-
413
- Remove-Item -LiteralPath $OldDir -Recurse -Force -ErrorAction SilentlyContinue
414
- if (Test-Path -LiteralPath $OldDir) {
415
- Write-Host "warning: could not fully remove $OldDir; delete it after closing running penguin processes."
416
- }
417
- } finally {
418
- Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
419
- if ($Staging -and (Test-Path -LiteralPath $Staging)) {
420
- Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
421
- }
422
- }
423
-
424
- # --- User PATH: append <install>\bin once only after the installed command is known to work.
425
- # Go through the registry, not [Environment]::*EnvironmentVariable: GetEnvironmentVariable
426
- # expands REG_EXPAND_SZ and SetEnvironmentVariable writes back REG_SZ, which would
427
- # irreversibly hard-code a user's %USERPROFILE%-style Path entries. Read the raw
428
- # (unexpanded) value, append to it, and write it back with its original value kind.
429
- # The registry only exists on Windows; skip the block elsewhere (functional test runs
430
- # of this script on pwsh/Linux — where the old API was a silent no-op anyway). ---
431
- # -NoModifyPath skips it: `penguin` resolves to the first bin directory on the
432
- # Path, and a second installation's entry there would answer whenever the first is absent.
433
- $BinDir = Join-Path $InstallDir "bin"
434
- $PathUpdateMessage = ""
435
- if (-not $NoModifyPath -and $env:OS -eq "Windows_NT") {
436
- $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true)
437
- if ($null -eq $EnvKey) { $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") }
438
- try {
439
- # Missing Path value: create it as REG_EXPAND_SZ (the kind Windows itself uses for Path).
440
- $Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
441
- try { $Kind = $EnvKey.GetValueKind("Path") } catch {}
442
- $RawPath = [string]$EnvKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
443
- # Membership is checked per entry after expansion, so both literal and %VAR%-style
444
- # spellings of the bin dir count as already present; the append itself stays raw.
445
- $OnPath = @($RawPath -split ";" | Where-Object { $_ } | ForEach-Object {
446
- [Environment]::ExpandEnvironmentVariables($_).TrimEnd("\")
447
- }) -contains $BinDir.TrimEnd("\")
448
- if (-not $OnPath) {
449
- $NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" }
450
- $EnvKey.SetValue("Path", $NewPath, $Kind)
451
- # A raw registry write does not broadcast WM_SETTINGCHANGE, so Explorer — and every
452
- # terminal window launched from it afterwards — would keep the stale Path until the next
453
- # logon. Broadcast it the way [Environment]::SetEnvironmentVariable would; best-effort,
454
- # a failure only means new terminals need a fresh logon to see the Path.
455
- try {
456
- if (-not ("PenguinInstaller.NativeMethods" -as [type])) {
457
- Add-Type -Namespace PenguinInstaller -Name NativeMethods -MemberDefinition @'
458
- [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
459
- public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
460
- '@
461
- }
462
- $BroadcastResult = [UIntPtr]::Zero
463
- # HWND_BROADCAST (0xffff), WM_SETTINGCHANGE (0x1a), SMTO_ABORTIFHUNG (0x2), 5s timeout.
464
- [PenguinInstaller.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero,
465
- "Environment", 2, 5000, [ref]$BroadcastResult) | Out-Null
466
- } catch {
467
- }
468
- $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)."
469
- }
470
- } finally {
471
- $EnvKey.Close()
472
- }
473
- }
474
- # Make `penguin` work in this session too.
475
- $PenguinCommand = "penguin"
476
- if (-not $NoModifyPath) {
477
- if (($env:Path -split ";") -notcontains $BinDir) { $env:Path = "$env:Path;$BinDir" }
478
- } else {
479
- $PenguinCommand = "& `"$(Join-Path $BinDir "penguin.cmd")`""
480
- }
481
-
482
- Write-Host ""
483
- Write-Host "Adelie $InstalledVersion installed to $InstallDir"
484
- if ($PathUpdateMessage) {
485
- Write-Host ""
486
- Write-Host $PathUpdateMessage
487
- }
488
- Write-Host ""
489
- Write-Host "Get started:"
490
- Write-Host " $PenguinCommand --help # all commands"
491
- Write-Host " $PenguinCommand web # start the Web UI at http://127.0.0.1:4000 (a first-login link is printed on first start)"
492
- Write-Host " $PenguinCommand server # headless server (PORT / HOST to override)"
1
+ # Adelie one-line installer for Windows.
2
+ #
3
+ # irm https://github.com/lmliheng/Adelie/releases/latest/download/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\.adelie
9
+ # $env:PENGUIN_ARCHIVE = "<file>" install a local Release zip without network access (same as -ArchivePath)
10
+ # $env:PENGUIN_DOWNLOAD_SOURCE = "auto|github" choose the online source; default auto (GitHub Releases)
11
+ # $env:PENGUIN_DOWNLOAD_BASE_URL = "https://..." exact online asset directory selected by the stable forwarder
12
+ # $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = "https://..." fallback for PENGUIN_DOWNLOAD_BASE_URL
13
+ # -NoModifyPath do not add <install>\bin to the user Path; for a second installation
14
+ # beside the one the `penguin` command belongs to
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 @adelie/cli` instead.
27
+ #
28
+ # The data dir (%USERPROFILE%\.adelie\data by default) 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
+ # The launcher exports the root it lives under, so an install made before the rename keeps its data.
31
+ #
32
+ # Docs: https://lmliheng.github.io/Adelie/docs/quickstart-cli
33
+ param(
34
+ [string]$Version = "",
35
+ [string]$InstallDir = "",
36
+ [string]$ArchivePath = "",
37
+ [switch]$NoModifyPath
38
+ )
39
+
40
+ $ErrorActionPreference = "Stop"
41
+ $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering slows downloads massively on PS 5.1
42
+
43
+ $Repo = "https://github.com/lmliheng/Adelie"
44
+ $GitHubReleaseRoot = "$Repo/releases/download"
45
+ $GitHubLatestBase = "$Repo/releases/latest/download"
46
+ $Asset = "penguin-win32-x64.zip"
47
+ $PayloadName = "payload.zip"
48
+ # The release workflow replaces this token with the immutable tag before publishing both the
49
+ # standalone installer and the copy sealed inside the Windows bundle.
50
+ $EmbeddedReleaseVersion = "v0.6.1"
51
+
52
+ function Fail([string]$Message) {
53
+ # `throw` rather than `exit`: a caller that runs this installer as an in-memory script block
54
+ # (rather than as a file) would otherwise have `exit` terminate its whole PowerShell session.
55
+ # `throw` aborts cleanly in both file and script-block runs.
56
+ throw "error: $Message"
57
+ }
58
+
59
+ # Lists a zip's top-level entry names without extracting (PS 5.1-safe; Expand-Archive itself
60
+ # uses the same .NET type). Used to tell an installer bundle (contains payload.zip) from a
61
+ # program archive (payload.zip itself, or a pre-0.1.6 release zip with a top-level penguin\).
62
+ function Get-ZipEntryNames([string]$ZipPath) {
63
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
64
+ $Zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
65
+ try {
66
+ return @($Zip.Entries | ForEach-Object { $_.FullName })
67
+ } finally {
68
+ $Zip.Dispose()
69
+ }
70
+ }
71
+
72
+ # Verifies a file against a sha256sum-format checksum file (`<hex> <filename>`; the first
73
+ # token is the hash). Checksums are never optional: every install path either downloads the
74
+ # published .sha256 or reads the one sealed inside the bundle.
75
+ function Assert-Sha256([string]$FilePath, [string]$ShaPath, [string]$Label) {
76
+ $Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
77
+ if (-not $Expected) { Fail "checksum file is empty or malformed: $ShaPath" }
78
+ $Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $FilePath).Hash
79
+ if ($Actual -ine $Expected) { Fail "checksum mismatch for $([IO.Path]::GetFileName($FilePath))." }
80
+ Write-Host "$Label checksum OK."
81
+ }
82
+
83
+ function Assert-HttpsUrl([string]$Name, [string]$Value) {
84
+ try { $Uri = [Uri]$Value } catch { Fail "$Name is not a valid URL" }
85
+ if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -ne "https") {
86
+ Fail "$Name must be an absolute HTTPS URL"
87
+ }
88
+ }
89
+
90
+ function Test-ReleaseTag([string]$Value) {
91
+ return $Value -match '^v[0-9A-Za-z][0-9A-Za-z._-]*$'
92
+ }
93
+
94
+ function Get-DownloadSourceLabel([string]$BaseUrl) {
95
+ try { $HostName = ([Uri]$BaseUrl).Host } catch { return "configured mirror" }
96
+ if ($HostName -eq "github.com") { return "GitHub" }
97
+ return "configured mirror"
98
+ }
99
+
100
+ function Get-ReleasePair(
101
+ [string]$BaseUrl,
102
+ [string]$ZipPath,
103
+ [string]$ShaPath
104
+ ) {
105
+ $Label = Get-DownloadSourceLabel $BaseUrl
106
+ Write-Host "Downloading $Asset from $Label ..."
107
+ Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue
108
+ try {
109
+ Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing
110
+ Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
111
+ return $true
112
+ } catch {
113
+ Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue
114
+ return $false
115
+ }
116
+ }
117
+
118
+ function Restore-PreviousInstall(
119
+ [string]$InstallDir,
120
+ [string]$OldDir,
121
+ [string[]]$MovedOld,
122
+ [string[]]$MovedNew
123
+ ) {
124
+ foreach ($d in @($MovedNew)) {
125
+ if (-not $d) { continue }
126
+ $Current = Join-Path $InstallDir $d
127
+ if (Test-Path -LiteralPath $Current) {
128
+ Remove-Item -LiteralPath $Current -Recurse -Force -ErrorAction Stop
129
+ }
130
+ }
131
+ foreach ($d in @($MovedOld)) {
132
+ if (-not $d) { continue }
133
+ $Previous = Join-Path $OldDir $d
134
+ if (Test-Path -LiteralPath $Previous) {
135
+ Move-Item -LiteralPath $Previous -Destination (Join-Path $InstallDir $d) -ErrorAction Stop
136
+ }
137
+ }
138
+ }
139
+
140
+ # --- Resolve options (parameters win over env vars, mirroring install.sh's --version) ---
141
+ if (-not $Version) { $Version = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } }
142
+ if (-not $InstallDir) {
143
+ $InstallDir = if ($env:PENGUIN_INSTALL_DIR) { $env:PENGUIN_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".adelie" }
144
+ }
145
+ if (-not $ArchivePath) {
146
+ $ArchivePath = if ($env:PENGUIN_ARCHIVE) { $env:PENGUIN_ARCHIVE } else { "" }
147
+ }
148
+ $DownloadBaseUrl = if ($env:PENGUIN_DOWNLOAD_BASE_URL) {
149
+ $env:PENGUIN_DOWNLOAD_BASE_URL.TrimEnd('/')
150
+ } else {
151
+ ""
152
+ }
153
+ $DownloadFallbackBaseUrl = if ($env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL) {
154
+ $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL.TrimEnd('/')
155
+ } else {
156
+ ""
157
+ }
158
+ if (-not $DownloadBaseUrl) { $DownloadFallbackBaseUrl = "" }
159
+ $SourceMode = if ($env:PENGUIN_DOWNLOAD_SOURCE) {
160
+ $env:PENGUIN_DOWNLOAD_SOURCE.ToLowerInvariant()
161
+ } else {
162
+ "auto"
163
+ }
164
+ # An extracted installer bundle keeps install.cmd, this script, payload.zip and its checksum
165
+ # together. `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs
166
+ # do not accidentally pick up an unrelated archive from the caller's current directory.
167
+ if (-not $ArchivePath -and $PSScriptRoot) {
168
+ $SiblingArchive = Join-Path $PSScriptRoot $PayloadName
169
+ if (Test-Path -LiteralPath $SiblingArchive -PathType Leaf) { $ArchivePath = $SiblingArchive }
170
+ }
171
+ if ($ArchivePath -and $Version) {
172
+ Fail "-ArchivePath/PENGUIN_ARCHIVE cannot be combined with -Version/PENGUIN_VERSION"
173
+ }
174
+ if ($Version -and -not (Test-ReleaseTag $Version)) {
175
+ Fail "invalid release version: $Version"
176
+ }
177
+ if ($SourceMode -notin @("auto", "github")) {
178
+ Fail "PENGUIN_DOWNLOAD_SOURCE must be auto or github"
179
+ }
180
+ $ResolvedReleaseVersion = if ($Version) {
181
+ $Version
182
+ } elseif (Test-ReleaseTag $EmbeddedReleaseVersion) {
183
+ $EmbeddedReleaseVersion
184
+ } else {
185
+ ""
186
+ }
187
+ if ($DownloadBaseUrl) {
188
+ Assert-HttpsUrl "PENGUIN_DOWNLOAD_BASE_URL" $DownloadBaseUrl
189
+ }
190
+ if ($DownloadBaseUrl -and $DownloadFallbackBaseUrl) {
191
+ Assert-HttpsUrl "PENGUIN_DOWNLOAD_FALLBACK_BASE_URL" $DownloadFallbackBaseUrl
192
+ }
193
+
194
+ # --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) ---
195
+ if (-not [Environment]::Is64BitOperatingSystem) {
196
+ Fail "32-bit Windows is not supported. Install Node.js >= 24 and use: npm install -g @adelie/cli"
197
+ }
198
+ if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
199
+ Write-Host "note: no native ARM64 package yet; installing the x64 package (runs via emulation)."
200
+ }
201
+
202
+ # PowerShell 5.1 defaults to TLS 1.0 on older systems; GitHub requires TLS 1.2+.
203
+ try {
204
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
205
+ } catch {
206
+ # .NET builds where the enum is immutable already default to TLS 1.2+.
207
+ }
208
+
209
+ # --- Download. An explicit forwarder/configured URL wins. Otherwise a stamped Release installer
210
+ # uses its own immutable version; an unstamped source-tree installer falls back to the
211
+ # release GitHub currently marks latest. ---
212
+ $Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID"
213
+ if (Test-Path $Tmp) { Remove-Item -Recurse -Force $Tmp }
214
+ New-Item -ItemType Directory -Path $Tmp | Out-Null
215
+ # Pre-declare so the finally block can read them even when an early failure skipped the
216
+ # assignments (the user's session may run this under Set-StrictMode via the forwarder).
217
+ $Staging = $null
218
+ $OldDir = $null
219
+
220
+ try {
221
+ $UsingLocalArchive = [bool]$ArchivePath
222
+ if ($UsingLocalArchive) {
223
+ try {
224
+ $ZipPath = (Resolve-Path -LiteralPath $ArchivePath -ErrorAction Stop).Path
225
+ } catch {
226
+ Fail "local archive not found: $ArchivePath"
227
+ }
228
+ $ArchiveName = [IO.Path]::GetFileName($ZipPath)
229
+ Write-Host "Using local archive $ZipPath ..."
230
+ } else {
231
+ $BaseUrl = ""
232
+ $FallbackBaseUrl = $DownloadFallbackBaseUrl
233
+ if ($DownloadBaseUrl) {
234
+ $BaseUrl = $DownloadBaseUrl
235
+ } elseif ($ResolvedReleaseVersion) {
236
+ $BaseUrl = "$GitHubReleaseRoot/$ResolvedReleaseVersion"
237
+ } else {
238
+ $BaseUrl = $GitHubLatestBase
239
+ }
240
+
241
+ # Online: download the canonical bundle; the published checksum is mandatory.
242
+ $ZipPath = Join-Path $Tmp $Asset
243
+ $ArchiveName = $Asset
244
+ $ShaPath = Join-Path $Tmp "$Asset.sha256"
245
+ if (-not (Get-ReleasePair $BaseUrl $ZipPath $ShaPath)) {
246
+ if ($FallbackBaseUrl -and $FallbackBaseUrl -ne $BaseUrl) {
247
+ Write-Host "Primary download source unavailable; trying $(Get-DownloadSourceLabel $FallbackBaseUrl) ..."
248
+ if (-not (Get-ReleasePair $FallbackBaseUrl $ZipPath $ShaPath)) {
249
+ Fail "download failed from both the primary source and its fallback. Check your network, then retry."
250
+ }
251
+ } else {
252
+ Fail "download failed from $(Get-DownloadSourceLabel $BaseUrl). Check the version tag and your network, then retry."
253
+ }
254
+ }
255
+ Assert-Sha256 $ZipPath $ShaPath "Bundle"
256
+ }
257
+
258
+ # --- Resolve the program payload. An installer bundle (contains payload.zip) is opened flat
259
+ # and its sealed payload checksum verified; a program archive (payload.zip itself, or a
260
+ # pre-0.1.6 release zip with a top-level penguin\) is used directly. ---
261
+ $ArchiveShape = if ((Get-ZipEntryNames $ZipPath) -contains $PayloadName) { "bundle" } else { "program" }
262
+ if ($ArchiveShape -eq "bundle") {
263
+ # A local bundle is self-verifying through its sealed payload checksum; when the published
264
+ # outer .sha256 was transferred alongside it, verify that layer too.
265
+ if ($UsingLocalArchive -and (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
266
+ Assert-Sha256 $ZipPath "$ZipPath.sha256" "Bundle"
267
+ }
268
+ $BundleDir = Join-Path $Tmp "bundle"
269
+ New-Item -ItemType Directory -Path $BundleDir | Out-Null
270
+ Write-Host "Opening installer bundle ..."
271
+ # The outer layer is flat (four files), so this extraction never creates deep paths.
272
+ Expand-Archive -LiteralPath $ZipPath -DestinationPath $BundleDir -Force
273
+ $ZipPath = Join-Path $BundleDir $PayloadName
274
+ if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) {
275
+ Fail "unexpected bundle layout: $PayloadName missing."
276
+ }
277
+ if (-not (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
278
+ Fail "unexpected bundle layout: $PayloadName.sha256 missing."
279
+ }
280
+ Assert-Sha256 $ZipPath "$ZipPath.sha256" "Payload"
281
+ } elseif ($UsingLocalArchive) {
282
+ # Program archive from disk: an adjacent checksum is required — its own, or the canonical
283
+ # asset checksum next to a renamed legacy file. (An online download was already verified
284
+ # against the published .sha256 above.)
285
+ $ShaPath = "$ZipPath.sha256"
286
+ if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf) -and
287
+ $ArchiveName -ine $Asset) {
288
+ $CanonicalShaPath = Join-Path ([IO.Path]::GetDirectoryName($ZipPath)) "$Asset.sha256"
289
+ if (Test-Path -LiteralPath $CanonicalShaPath -PathType Leaf) {
290
+ $ShaPath = $CanonicalShaPath
291
+ }
292
+ }
293
+ if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf)) {
294
+ Fail "offline checksum file not found: $ShaPath"
295
+ }
296
+ Assert-Sha256 $ZipPath $ShaPath "Payload"
297
+ }
298
+
299
+ # --- Extract into staging, then swap by same-volume renames. Keep the previous dirs in .old.$PID
300
+ # until the installed command runs successfully; any move or launch failure restores them.
301
+ # The data dir (%USERPROFILE%\.adelie\data by default) is never part of the swap. ---
302
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
303
+ $Staging = Join-Path $InstallDir ".staging.$PID"
304
+ $OldDir = Join-Path $InstallDir ".old.$PID"
305
+ if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
306
+ if (Test-Path $OldDir) { Remove-Item -Recurse -Force $OldDir }
307
+ New-Item -ItemType Directory -Path $Staging | Out-Null
308
+
309
+ Write-Host "Extracting ..."
310
+ Expand-Archive -LiteralPath $ZipPath -DestinationPath $Staging -Force
311
+ $NewRoot = Join-Path $Staging "penguin"
312
+ if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." }
313
+ if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." }
314
+ $ManifestPath = Join-Path $NewRoot "package-manifest.json"
315
+ if (Test-Path -LiteralPath $ManifestPath -PathType Leaf) {
316
+ try {
317
+ $PackageManifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
318
+ } catch {
319
+ Fail "package manifest is malformed: $($_.Exception.Message)"
320
+ }
321
+ $TargetProperty = $PackageManifest.PSObject.Properties["target"]
322
+ if ($null -eq $TargetProperty -or -not $TargetProperty.Value) {
323
+ Fail "package manifest is malformed: target missing."
324
+ }
325
+ if ([string]$TargetProperty.Value -ine "win32-x64") {
326
+ Fail "package target mismatch: expected win32-x64, found $($TargetProperty.Value)."
327
+ }
328
+ } elseif ($UsingLocalArchive -and $ArchiveShape -eq "program" -and
329
+ $ArchiveName -ine $Asset -and $ArchiveName -ine $PayloadName) {
330
+ Fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
331
+ }
332
+
333
+ $Dirs = @("bin", "lib", "web", "node", "git")
334
+ $MovedOld = @()
335
+ $MovedNew = @()
336
+ New-Item -ItemType Directory -Path $OldDir | Out-Null
337
+ try {
338
+ foreach ($d in $Dirs) {
339
+ $Existing = Join-Path $InstallDir $d
340
+ if (Test-Path -LiteralPath $Existing) {
341
+ Move-Item -LiteralPath $Existing -Destination (Join-Path $OldDir $d)
342
+ $MovedOld += $d
343
+ }
344
+ }
345
+ foreach ($d in $Dirs) {
346
+ $Src = Join-Path $NewRoot $d
347
+ if (Test-Path -LiteralPath $Src) {
348
+ Move-Item -LiteralPath $Src -Destination (Join-Path $InstallDir $d)
349
+ $MovedNew += $d
350
+ }
351
+ }
352
+
353
+ # --- Launcher shim: shipped in the payload; (re)generated only when missing. The two
354
+ # variables it fills in use Adelie's spelling, with the pre-rename PENGUIN_WEB_DIST
355
+ # still honored, exactly like scripts/launchers/penguin.cmd — a shim regenerated here
356
+ # must not behave differently from the one the payload ships. There is
357
+ # deliberately no penguin.ps1 launcher — PowerShell would prefer it over penguin.cmd on
358
+ # PATH, and client Windows defaults to the Restricted execution policy, so a .ps1
359
+ # launcher makes the plain `penguin` command fail with "running scripts is disabled".
360
+ # Batch files are policy-exempt and both PowerShell and cmd.exe resolve penguin.cmd.
361
+ # (bin\ is swapped wholesale above, so upgrading also removes the penguin.ps1 that
362
+ # pre-0.1.6 payloads shipped.) ---
363
+ $CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
364
+ if (-not (Test-Path -LiteralPath $CmdShim)) {
365
+ @(
366
+ '@echo off'
367
+ 'setlocal'
368
+ 'set "DIR=%~dp0.."'
369
+ 'if not defined ADELIE_WEB_DIST if defined PENGUIN_WEB_DIST set "ADELIE_WEB_DIST=%PENGUIN_WEB_DIST%"'
370
+ 'if not defined ADELIE_WEB_DIST set "ADELIE_WEB_DIST=%DIR%\web"'
371
+ 'if exist "%DIR%\git\usr\bin\sh.exe" set "ADELIE_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe"'
372
+ 'if exist "%DIR%\node\node.exe" ('
373
+ ' "%DIR%\node\node.exe" "%DIR%\lib\dist\penguin.js" %*'
374
+ ') else ('
375
+ ' node "%DIR%\lib\dist\penguin.js" %*'
376
+ ')'
377
+ 'exit /b %ERRORLEVEL%'
378
+ ) | Set-Content -LiteralPath $CmdShim -Encoding ascii
379
+ }
380
+ if (-not (Test-Path -LiteralPath $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
381
+
382
+ # Verify from the final path before deleting the backup. Keep stderr visible so platform
383
+ # policy, permission and runtime errors are not disguised as an "unknown" version.
384
+ # Windows PowerShell 5.1 turns native stderr into NativeCommandError records. Temporarily
385
+ # keep those non-terminating so the real stderr stays visible and the exit code remains the
386
+ # authoritative result; restore Stop immediately afterwards for installer operations.
387
+ $PreviousErrorActionPreference = $ErrorActionPreference
388
+ try {
389
+ $ErrorActionPreference = "Continue"
390
+ $VersionOutput = @(& $CmdShim --version)
391
+ $VersionExitCode = $LASTEXITCODE
392
+ } finally {
393
+ $ErrorActionPreference = $PreviousErrorActionPreference
394
+ }
395
+ if ($VersionExitCode -ne 0) {
396
+ Fail "installed Adelie failed to run (exit code $VersionExitCode). See the error above."
397
+ }
398
+ $InstalledVersion = $VersionOutput | Select-Object -First 1
399
+ if ([string]::IsNullOrWhiteSpace([string]$InstalledVersion)) {
400
+ Fail "installed Adelie returned an empty version."
401
+ }
402
+ } catch {
403
+ $InstallFailure = $_
404
+ try {
405
+ Restore-PreviousInstall -InstallDir $InstallDir -OldDir $OldDir -MovedOld $MovedOld -MovedNew $MovedNew
406
+ Write-Host "Previous Adelie installation restored."
407
+ } catch {
408
+ throw "error: installation failed and automatic rollback was incomplete. Original error: $($InstallFailure.Exception.Message) Rollback error: $($_.Exception.Message) Previous files may remain in $OldDir"
409
+ }
410
+ throw $InstallFailure
411
+ }
412
+
413
+ Remove-Item -LiteralPath $OldDir -Recurse -Force -ErrorAction SilentlyContinue
414
+ if (Test-Path -LiteralPath $OldDir) {
415
+ Write-Host "warning: could not fully remove $OldDir; delete it after closing running penguin processes."
416
+ }
417
+ } finally {
418
+ Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
419
+ if ($Staging -and (Test-Path -LiteralPath $Staging)) {
420
+ Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
421
+ }
422
+ }
423
+
424
+ # --- User PATH: append <install>\bin once only after the installed command is known to work.
425
+ # Go through the registry, not [Environment]::*EnvironmentVariable: GetEnvironmentVariable
426
+ # expands REG_EXPAND_SZ and SetEnvironmentVariable writes back REG_SZ, which would
427
+ # irreversibly hard-code a user's %USERPROFILE%-style Path entries. Read the raw
428
+ # (unexpanded) value, append to it, and write it back with its original value kind.
429
+ # The registry only exists on Windows; skip the block elsewhere (functional test runs
430
+ # of this script on pwsh/Linux — where the old API was a silent no-op anyway). ---
431
+ # -NoModifyPath skips it: `penguin` resolves to the first bin directory on the
432
+ # Path, and a second installation's entry there would answer whenever the first is absent.
433
+ $BinDir = Join-Path $InstallDir "bin"
434
+ $PathUpdateMessage = ""
435
+ if (-not $NoModifyPath -and $env:OS -eq "Windows_NT") {
436
+ $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true)
437
+ if ($null -eq $EnvKey) { $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") }
438
+ try {
439
+ # Missing Path value: create it as REG_EXPAND_SZ (the kind Windows itself uses for Path).
440
+ $Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
441
+ try { $Kind = $EnvKey.GetValueKind("Path") } catch {}
442
+ $RawPath = [string]$EnvKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
443
+ # Membership is checked per entry after expansion, so both literal and %VAR%-style
444
+ # spellings of the bin dir count as already present; the append itself stays raw.
445
+ $OnPath = @($RawPath -split ";" | Where-Object { $_ } | ForEach-Object {
446
+ [Environment]::ExpandEnvironmentVariables($_).TrimEnd("\")
447
+ }) -contains $BinDir.TrimEnd("\")
448
+ if (-not $OnPath) {
449
+ $NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" }
450
+ $EnvKey.SetValue("Path", $NewPath, $Kind)
451
+ # A raw registry write does not broadcast WM_SETTINGCHANGE, so Explorer — and every
452
+ # terminal window launched from it afterwards — would keep the stale Path until the next
453
+ # logon. Broadcast it the way [Environment]::SetEnvironmentVariable would; best-effort,
454
+ # a failure only means new terminals need a fresh logon to see the Path.
455
+ try {
456
+ if (-not ("PenguinInstaller.NativeMethods" -as [type])) {
457
+ Add-Type -Namespace PenguinInstaller -Name NativeMethods -MemberDefinition @'
458
+ [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
459
+ public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
460
+ '@
461
+ }
462
+ $BroadcastResult = [UIntPtr]::Zero
463
+ # HWND_BROADCAST (0xffff), WM_SETTINGCHANGE (0x1a), SMTO_ABORTIFHUNG (0x2), 5s timeout.
464
+ [PenguinInstaller.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero,
465
+ "Environment", 2, 5000, [ref]$BroadcastResult) | Out-Null
466
+ } catch {
467
+ }
468
+ $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)."
469
+ }
470
+ } finally {
471
+ $EnvKey.Close()
472
+ }
473
+ }
474
+ # Make `penguin` work in this session too.
475
+ $PenguinCommand = "penguin"
476
+ if (-not $NoModifyPath) {
477
+ if (($env:Path -split ";") -notcontains $BinDir) { $env:Path = "$env:Path;$BinDir" }
478
+ } else {
479
+ $PenguinCommand = "& `"$(Join-Path $BinDir "penguin.cmd")`""
480
+ }
481
+
482
+ Write-Host ""
483
+ Write-Host "Adelie $InstalledVersion installed to $InstallDir"
484
+ if ($PathUpdateMessage) {
485
+ Write-Host ""
486
+ Write-Host $PathUpdateMessage
487
+ }
488
+ Write-Host ""
489
+ Write-Host "Get started:"
490
+ Write-Host " $PenguinCommand --help # all commands"
491
+ Write-Host " $PenguinCommand web # start the Web UI at http://127.0.0.1:4000 (a first-login link is printed on first start)"
492
+ Write-Host " $PenguinCommand server # headless server (PORT / HOST to override)"