fix(release): harden cross-platform offline installers (#131)
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
# PowerShell is the one Windows-native text format we ship; keep it CRLF for notepad/PS 5.1
|
||||
# friendliness (PowerShell itself accepts both).
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binary assets: never subject to text normalization or diffs.
|
||||
*.png binary
|
||||
|
||||
@@ -45,6 +45,9 @@ jobs:
|
||||
- name: Unit tests (vitest)
|
||||
run: pnpm test
|
||||
|
||||
- name: Offline bundle and POSIX installer tests
|
||||
run: sh scripts/test-offline-bundles.sh
|
||||
|
||||
# The secret is exposed only in this step (step-level env); earlier steps and third-party actions can't see it.
|
||||
# The secrets context can't be used in if expressions, so skip inside the shell when it's absent (e.g. forks).
|
||||
- name: E2E (live LLM via DeepSeek)
|
||||
@@ -108,3 +111,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- name: Windows offline installer tests
|
||||
shell: pwsh
|
||||
run: ./scripts/test-offline-install.ps1
|
||||
|
||||
@@ -110,12 +110,13 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
# lib/: the CLI and its production deps (including workspace core/server/skills, all build outputs);
|
||||
# pnpm 10's deploy needs --legacy (this repo doesn't enable inject-workspace-packages).
|
||||
# Use pnpm's current deploy implementation with injected workspace packages; the hoisted target layout
|
||||
# avoids deeply nested .pnpm/node_modules paths that exceed Windows PowerShell 5.1's legacy MAX_PATH limit.
|
||||
# bin/penguin launcher: resolve its own real path (following symlinks) -> default PENGUIN_WEB_DIST to
|
||||
# the sibling web/ -> use the bundled runtime (node/bin/node) if present, else fall back to system node.
|
||||
- name: Assemble penguin/ (lib + web + bin)
|
||||
run: |
|
||||
pnpm --filter @prismshadow/penguin-cli --prod deploy --legacy "$PWD/out/penguin/lib"
|
||||
pnpm --config.node-linker=hoisted --filter @prismshadow/penguin-cli --prod deploy "$PWD/out/penguin/lib"
|
||||
cp -r packages/web/dist out/penguin/web
|
||||
mkdir -p out/penguin/bin
|
||||
cat > out/penguin/bin/penguin <<'EOF'
|
||||
@@ -155,10 +156,12 @@ jobs:
|
||||
fi
|
||||
mv "/tmp/node-runtime/$name" out/penguin/node
|
||||
rm -rf out/penguin/node/share/doc out/penguin/node/share/man
|
||||
printf '{"schemaVersion":1,"target":"%s"}\n' "$os-$arch" > out/penguin/package-manifest.json
|
||||
tar -czf "dist-artifacts/penguin-$os-$arch.tar.gz" -C out penguin
|
||||
done
|
||||
# Universal package: no bundled runtime, requires system Node >= 24.
|
||||
rm -rf out/penguin/node
|
||||
printf '{"schemaVersion":1,"target":"universal"}\n' > out/penguin/package-manifest.json
|
||||
tar -czf dist-artifacts/penguin-universal.tar.gz -C out penguin
|
||||
|
||||
# Windows package: same lib/ + web/ layout, but a .zip (the native format), the official
|
||||
@@ -192,6 +195,7 @@ jobs:
|
||||
test -f out/penguin/git/usr/bin/sh.exe
|
||||
test -f out/penguin/git/etc/profile
|
||||
rm -f out/penguin/bin/penguin
|
||||
printf '{"schemaVersion":1,"target":"win32-x64"}\n' > out/penguin/package-manifest.json
|
||||
cat > out/penguin/bin/penguin.cmd <<'EOF'
|
||||
@echo off
|
||||
setlocal
|
||||
@@ -221,15 +225,26 @@ jobs:
|
||||
sed -i 's/$/\r/' out/penguin/bin/penguin.ps1
|
||||
(cd out && zip -qr ../dist-artifacts/penguin-win32-x64.zip penguin)
|
||||
|
||||
# SHA256SUMS summary + a same-named .sha256 per artifact (install.sh / install.ps1 verify against the latter).
|
||||
- name: Generate SHA256 checksums
|
||||
# Per-payload checksums are included inside the offline bundles and are also consumed by
|
||||
# the online install.sh / install.ps1 downloads.
|
||||
- name: Generate payload SHA256 checksums
|
||||
run: |
|
||||
cd dist-artifacts
|
||||
sha256sum *.tar.gz *.zip > SHA256SUMS
|
||||
for f in *.tar.gz *.zip; do
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
done
|
||||
|
||||
# Each offline bundle contains exactly one platform payload, its checksum and the native installer.
|
||||
# Users extract once, then run install.sh (Linux/macOS) or double-click install.cmd (Windows).
|
||||
- name: Package offline installer bundles
|
||||
run: sh scripts/package-offline-bundles.sh dist-artifacts
|
||||
|
||||
# Summary covers both raw program archives and their offline installer wrappers.
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
cd dist-artifacts
|
||||
sha256sum *.tar.gz *.zip > SHA256SUMS
|
||||
|
||||
# Release notes come from changelog/<version>/RELEASE.md, written during release preparation and
|
||||
# committed BEFORE the tag (the release job runs on the tag's checkout, so a file added afterwards
|
||||
# is invisible here). Present and non-empty -> published verbatim as the body; absent -> GitHub
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Multi-platform offline installer bundles
|
||||
|
||||
- Release builds now wrap each Windows, Linux and macOS platform archive with its checksum and native installer, producing five self-contained offline bundles.
|
||||
- `install.sh` and `install.ps1` can install a verified local Release archive without network access while preserving their existing online behavior.
|
||||
- POSIX offline bundles use a dedicated entry point that passes their payload explicitly, so the online installer never trusts archives discovered beside a temporary script.
|
||||
- Platform packages carry a target manifest, allowing explicit local archive paths to be renamed without relying on the filename for compatibility checks.
|
||||
- Windows offline bundles include `install.cmd` as a double-click entry point; Linux and macOS bundles keep `install.sh` executable.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" -ArchivePath "%~dp0penguin-win32-x64.zip"
|
||||
set "INSTALL_EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
if "%INSTALL_EXIT_CODE%"=="0" (
|
||||
echo PenguinHarness offline installation completed.
|
||||
) else (
|
||||
echo PenguinHarness offline installation failed.
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
exit /b %INSTALL_EXIT_CODE%
|
||||
+187
-78
@@ -5,6 +5,7 @@
|
||||
# Options:
|
||||
# $env:PENGUIN_VERSION = "vX.Y.Z" pin a version (same as -Version vX.Y.Z); default is the latest Release
|
||||
# $env:PENGUIN_INSTALL_DIR = "<dir>" install dir; default $env:USERPROFILE\.penguin
|
||||
# $env:PENGUIN_ARCHIVE = "<file>" install a local Release zip without network access (same as -ArchivePath)
|
||||
#
|
||||
# There is no -Universal on Windows: where the zip is unsuitable, install Node.js >= 24 and run
|
||||
# `npm install -g @prismshadow/penguin-cli` instead.
|
||||
@@ -15,7 +16,8 @@
|
||||
# Docs: https://penguin.ooo/docs/installation
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$InstallDir = ""
|
||||
[string]$InstallDir = "",
|
||||
[string]$ArchivePath = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -31,11 +33,46 @@ function Fail([string]$Message) {
|
||||
throw "error: $Message"
|
||||
}
|
||||
|
||||
function Restore-PreviousInstall(
|
||||
[string]$InstallDir,
|
||||
[string]$OldDir,
|
||||
[string[]]$MovedOld,
|
||||
[string[]]$MovedNew
|
||||
) {
|
||||
foreach ($d in @($MovedNew)) {
|
||||
if (-not $d) { continue }
|
||||
$Current = Join-Path $InstallDir $d
|
||||
if (Test-Path -LiteralPath $Current) {
|
||||
Remove-Item -LiteralPath $Current -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
foreach ($d in @($MovedOld)) {
|
||||
if (-not $d) { continue }
|
||||
$Previous = Join-Path $OldDir $d
|
||||
if (Test-Path -LiteralPath $Previous) {
|
||||
Move-Item -LiteralPath $Previous -Destination (Join-Path $InstallDir $d) -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Resolve options (parameters win over env vars, mirroring install.sh's --version) ---
|
||||
if (-not $Version) { $Version = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } }
|
||||
if (-not $InstallDir) {
|
||||
$InstallDir = if ($env:PENGUIN_INSTALL_DIR) { $env:PENGUIN_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".penguin" }
|
||||
}
|
||||
if (-not $ArchivePath) {
|
||||
$ArchivePath = if ($env:PENGUIN_ARCHIVE) { $env:PENGUIN_ARCHIVE } else { "" }
|
||||
}
|
||||
# An extracted offline bundle keeps this script, the Windows zip and its checksum together.
|
||||
# `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs do not
|
||||
# accidentally pick up an unrelated archive from the caller's current directory.
|
||||
if (-not $ArchivePath -and $PSScriptRoot) {
|
||||
$SiblingArchive = Join-Path $PSScriptRoot $Asset
|
||||
if (Test-Path -LiteralPath $SiblingArchive -PathType Leaf) { $ArchivePath = $SiblingArchive }
|
||||
}
|
||||
if ($ArchivePath -and $Version) {
|
||||
Fail "-ArchivePath/PENGUIN_ARCHIVE cannot be combined with -Version/PENGUIN_VERSION"
|
||||
}
|
||||
|
||||
# --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) ---
|
||||
if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
@@ -67,27 +104,53 @@ $Staging = $null
|
||||
$OldDir = $null
|
||||
|
||||
try {
|
||||
Write-Host "Downloading $BaseUrl/$Asset ..."
|
||||
$ZipPath = Join-Path $Tmp $Asset
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing
|
||||
} catch {
|
||||
Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
|
||||
$UsingLocalArchive = [bool]$ArchivePath
|
||||
if ($UsingLocalArchive) {
|
||||
try {
|
||||
$ZipPath = (Resolve-Path -LiteralPath $ArchivePath -ErrorAction Stop).Path
|
||||
} catch {
|
||||
Fail "local archive not found: $ArchivePath"
|
||||
}
|
||||
$ArchiveName = [IO.Path]::GetFileName($ZipPath)
|
||||
Write-Host "Using local archive $ZipPath ..."
|
||||
} else {
|
||||
Write-Host "Downloading $BaseUrl/$Asset ..."
|
||||
$ZipPath = Join-Path $Tmp $Asset
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing
|
||||
} catch {
|
||||
Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
|
||||
}
|
||||
$ArchiveName = $Asset
|
||||
}
|
||||
|
||||
# --- SHA256 verify: only when the .sha256 asset exists (skip on 404) ---
|
||||
$ShaPath = Join-Path $Tmp "$Asset.sha256"
|
||||
# --- SHA256 verify: mandatory offline; online keeps the existing warn-and-skip fallback. ---
|
||||
$HaveSha = $true
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
|
||||
} catch {
|
||||
$HaveSha = $false
|
||||
Write-Host "warning: checksum file not available; skipping verification."
|
||||
if ($UsingLocalArchive) {
|
||||
$ShaPath = "$ZipPath.sha256"
|
||||
if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf) -and
|
||||
$ArchiveName -ine $Asset) {
|
||||
$CanonicalShaPath = Join-Path ([IO.Path]::GetDirectoryName($ZipPath)) "$Asset.sha256"
|
||||
if (Test-Path -LiteralPath $CanonicalShaPath -PathType Leaf) {
|
||||
$ShaPath = $CanonicalShaPath
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf)) {
|
||||
Fail "offline checksum file not found: $ShaPath"
|
||||
}
|
||||
} else {
|
||||
$ShaPath = Join-Path $Tmp "$Asset.sha256"
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
|
||||
} catch {
|
||||
$HaveSha = $false
|
||||
Write-Host "warning: checksum file not available; skipping verification."
|
||||
}
|
||||
}
|
||||
if ($HaveSha) {
|
||||
# The .sha256 file is `<hex> <filename>` (sha256sum format); the first token is the hash.
|
||||
$Expected = ((Get-Content $ShaPath -Raw).Trim() -split "\s+")[0]
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash
|
||||
$Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $ZipPath).Hash
|
||||
if ($Expected -and ($Actual -ieq $Expected)) {
|
||||
Write-Host "Checksum OK."
|
||||
} else {
|
||||
@@ -95,10 +158,9 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Extract and swap into place: expand into a staging dir under the install dir (same volume,
|
||||
# so the swap below is cheap renames), then rename-then-delete: move the old dirs aside first
|
||||
# (a locked file fails fast here, before anything is deleted), move the new ones in, then
|
||||
# drop the old. The data dir (%USERPROFILE%\.penguin\data) is untouched. ---
|
||||
# --- Extract into staging, then swap by same-volume renames. Keep the previous dirs in .old.$PID
|
||||
# until the installed command runs successfully; any move or launch failure restores them.
|
||||
# The data dir (%USERPROFILE%\.penguin\data) is never part of the swap. ---
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$Staging = Join-Path $InstallDir ".staging.$PID"
|
||||
$OldDir = Join-Path $InstallDir ".old.$PID"
|
||||
@@ -107,77 +169,123 @@ try {
|
||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
||||
|
||||
Write-Host "Extracting ..."
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $Staging -Force
|
||||
Expand-Archive -LiteralPath $ZipPath -DestinationPath $Staging -Force
|
||||
$NewRoot = Join-Path $Staging "penguin"
|
||||
if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." }
|
||||
if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." }
|
||||
$ManifestPath = Join-Path $NewRoot "package-manifest.json"
|
||||
if (Test-Path -LiteralPath $ManifestPath -PathType Leaf) {
|
||||
try {
|
||||
$PackageManifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
Fail "package manifest is malformed: $($_.Exception.Message)"
|
||||
}
|
||||
$TargetProperty = $PackageManifest.PSObject.Properties["target"]
|
||||
if ($null -eq $TargetProperty -or -not $TargetProperty.Value) {
|
||||
Fail "package manifest is malformed: target missing."
|
||||
}
|
||||
if ([string]$TargetProperty.Value -ine "win32-x64") {
|
||||
Fail "package target mismatch: expected win32-x64, found $($TargetProperty.Value)."
|
||||
}
|
||||
} elseif ($UsingLocalArchive -and $ArchiveName -ine $Asset) {
|
||||
Fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
|
||||
}
|
||||
|
||||
$Dirs = @("bin", "lib", "web", "node", "git")
|
||||
$Moved = @()
|
||||
$MovedOld = @()
|
||||
$MovedNew = @()
|
||||
New-Item -ItemType Directory -Path $OldDir | Out-Null
|
||||
try {
|
||||
foreach ($d in $Dirs) {
|
||||
$Existing = Join-Path $InstallDir $d
|
||||
if (Test-Path $Existing) {
|
||||
Move-Item -Path $Existing -Destination (Join-Path $OldDir $d)
|
||||
$Moved += $d
|
||||
if (Test-Path -LiteralPath $Existing) {
|
||||
Move-Item -LiteralPath $Existing -Destination (Join-Path $OldDir $d)
|
||||
$MovedOld += $d
|
||||
}
|
||||
}
|
||||
foreach ($d in $Dirs) {
|
||||
$Src = Join-Path $NewRoot $d
|
||||
if (Test-Path -LiteralPath $Src) {
|
||||
Move-Item -LiteralPath $Src -Destination (Join-Path $InstallDir $d)
|
||||
$MovedNew += $d
|
||||
}
|
||||
}
|
||||
|
||||
# --- Launcher shims: shipped in the zip; (re)generate only when missing. ---
|
||||
$CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
|
||||
if (-not (Test-Path -LiteralPath $CmdShim)) {
|
||||
@(
|
||||
'@echo off'
|
||||
'setlocal'
|
||||
'set "DIR=%~dp0.."'
|
||||
'if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"'
|
||||
'if exist "%DIR%\git\usr\bin\sh.exe" set "PENGUIN_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe"'
|
||||
'if exist "%DIR%\node\node.exe" ('
|
||||
' "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*'
|
||||
') else ('
|
||||
' node "%DIR%\lib\dist\index.js" %*'
|
||||
')'
|
||||
'exit /b %ERRORLEVEL%'
|
||||
) | Set-Content -LiteralPath $CmdShim -Encoding ascii
|
||||
}
|
||||
$Ps1Shim = Join-Path $InstallDir "bin\penguin.ps1"
|
||||
if (-not (Test-Path -LiteralPath $Ps1Shim)) {
|
||||
@(
|
||||
'$dir = Split-Path -Parent $PSScriptRoot'
|
||||
'if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }'
|
||||
'$sh = Join-Path $dir "git\usr\bin\sh.exe"'
|
||||
'if (Test-Path $sh) { $env:PENGUIN_BUNDLED_SHELL = $sh }'
|
||||
'$node = Join-Path $dir "node\node.exe"'
|
||||
'if (-not (Test-Path $node)) { $node = "node" }'
|
||||
'& $node (Join-Path $dir "lib\dist\index.js") @args'
|
||||
'exit $LASTEXITCODE'
|
||||
) | Set-Content -LiteralPath $Ps1Shim -Encoding ascii
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
|
||||
|
||||
# Verify from the final path before deleting the backup. Keep stderr visible so platform
|
||||
# policy, permission and runtime errors are not disguised as an "unknown" version.
|
||||
# Windows PowerShell 5.1 turns native stderr into NativeCommandError records. Temporarily
|
||||
# keep those non-terminating so the real stderr stays visible and the exit code remains the
|
||||
# authoritative result; restore Stop immediately afterwards for installer operations.
|
||||
$PreviousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = "Continue"
|
||||
$VersionOutput = @(& $CmdShim --version)
|
||||
$VersionExitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $PreviousErrorActionPreference
|
||||
}
|
||||
if ($VersionExitCode -ne 0) {
|
||||
Fail "installed PenguinHarness failed to run (exit code $VersionExitCode). See the error above."
|
||||
}
|
||||
$InstalledVersion = $VersionOutput | Select-Object -First 1
|
||||
if ([string]::IsNullOrWhiteSpace([string]$InstalledVersion)) {
|
||||
Fail "installed PenguinHarness returned an empty version."
|
||||
}
|
||||
} catch {
|
||||
# Roll the already-moved dirs back so a locked install stays intact and usable.
|
||||
foreach ($d in $Moved) {
|
||||
Move-Item -Path (Join-Path $OldDir $d) -Destination (Join-Path $InstallDir $d) -ErrorAction SilentlyContinue
|
||||
$InstallFailure = $_
|
||||
try {
|
||||
Restore-PreviousInstall -InstallDir $InstallDir -OldDir $OldDir -MovedOld $MovedOld -MovedNew $MovedNew
|
||||
Write-Host "Previous PenguinHarness installation restored."
|
||||
} catch {
|
||||
throw "error: installation failed and automatic rollback was incomplete. Original error: $($InstallFailure.Exception.Message) Rollback error: $($_.Exception.Message) Previous files may remain in $OldDir"
|
||||
}
|
||||
Fail "files in $InstallDir are locked: close running penguin processes (and any node.exe they started), then retry. ($($_.Exception.Message))"
|
||||
throw $InstallFailure
|
||||
}
|
||||
foreach ($d in $Dirs) {
|
||||
$Src = Join-Path $NewRoot $d
|
||||
if (Test-Path $Src) {
|
||||
Move-Item -Path $Src -Destination (Join-Path $InstallDir $d)
|
||||
}
|
||||
}
|
||||
Remove-Item -Recurse -Force $OldDir -ErrorAction SilentlyContinue
|
||||
if (Test-Path $OldDir) {
|
||||
Write-Host "warning: could not fully remove $OldDir (files in use); delete it after closing running penguin processes."
|
||||
|
||||
Remove-Item -LiteralPath $OldDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $OldDir) {
|
||||
Write-Host "warning: could not fully remove $OldDir; delete it after closing running penguin processes."
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue
|
||||
if ($Staging -and (Test-Path $Staging)) { Remove-Item -Recurse -Force $Staging -ErrorAction SilentlyContinue }
|
||||
Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($Staging -and (Test-Path -LiteralPath $Staging)) {
|
||||
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# --- Launcher shims: shipped in the zip; (re)generate only when missing ---
|
||||
$CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
|
||||
if (-not (Test-Path $CmdShim)) {
|
||||
@(
|
||||
'@echo off'
|
||||
'setlocal'
|
||||
'set "DIR=%~dp0.."'
|
||||
'if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"'
|
||||
'if exist "%DIR%\git\usr\bin\sh.exe" set "PENGUIN_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe"'
|
||||
'if exist "%DIR%\node\node.exe" ('
|
||||
' "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*'
|
||||
') else ('
|
||||
' node "%DIR%\lib\dist\index.js" %*'
|
||||
')'
|
||||
'exit /b %ERRORLEVEL%'
|
||||
) | Set-Content -Path $CmdShim -Encoding ascii
|
||||
}
|
||||
$Ps1Shim = Join-Path $InstallDir "bin\penguin.ps1"
|
||||
if (-not (Test-Path $Ps1Shim)) {
|
||||
@(
|
||||
'$dir = Split-Path -Parent $PSScriptRoot'
|
||||
'if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }'
|
||||
'$sh = Join-Path $dir "git\usr\bin\sh.exe"'
|
||||
'if (Test-Path $sh) { $env:PENGUIN_BUNDLED_SHELL = $sh }'
|
||||
'$node = Join-Path $dir "node\node.exe"'
|
||||
'if (-not (Test-Path $node)) { $node = "node" }'
|
||||
'& $node (Join-Path $dir "lib\dist\index.js") @args'
|
||||
'exit $LASTEXITCODE'
|
||||
) | Set-Content -Path $Ps1Shim -Encoding ascii
|
||||
}
|
||||
if (-not (Test-Path $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
|
||||
|
||||
# --- User PATH: append <install>\bin once; new terminals pick it up.
|
||||
# --- User PATH: append <install>\bin once only after the installed command is known to work.
|
||||
# Go through the registry, not [Environment]::*EnvironmentVariable: GetEnvironmentVariable
|
||||
# expands REG_EXPAND_SZ and SetEnvironmentVariable writes back REG_SZ, which would
|
||||
# irreversibly hard-code a user's %USERPROFILE%-style Path entries. Read the raw
|
||||
@@ -185,6 +293,7 @@ if (-not (Test-Path $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
|
||||
# The registry only exists on Windows; skip the block elsewhere (functional test runs
|
||||
# of this script on pwsh/Linux — where the old API was a silent no-op anyway). ---
|
||||
$BinDir = Join-Path $InstallDir "bin"
|
||||
$PathUpdateMessage = ""
|
||||
if ($env:OS -eq "Windows_NT") {
|
||||
$EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true)
|
||||
if ($null -eq $EnvKey) { $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") }
|
||||
@@ -201,8 +310,7 @@ if ($env:OS -eq "Windows_NT") {
|
||||
if (-not $OnPath) {
|
||||
$NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" }
|
||||
$EnvKey.SetValue("Path", $NewPath, $Kind)
|
||||
Write-Host ""
|
||||
Write-Host "note: appended $BinDir to your user Path. Restart your terminal so 'penguin' is found."
|
||||
$PathUpdateMessage = "note: installation succeeded and $BinDir was appended to your user Path. Restart your terminal so 'penguin' is found."
|
||||
}
|
||||
} finally {
|
||||
$EnvKey.Close()
|
||||
@@ -211,11 +319,12 @@ if ($env:OS -eq "Windows_NT") {
|
||||
# Make `penguin` work in this session too.
|
||||
if (($env:Path -split ";") -notcontains $BinDir) { $env:Path = "$env:Path;$BinDir" }
|
||||
|
||||
# --- Finish: print version and getting-started tips ---
|
||||
$InstalledVersion = "unknown"
|
||||
try { $InstalledVersion = (& $CmdShim --version 2>$null | Select-Object -First 1) } catch {}
|
||||
Write-Host ""
|
||||
Write-Host "PenguinHarness $InstalledVersion installed to $InstallDir"
|
||||
if ($PathUpdateMessage) {
|
||||
Write-Host ""
|
||||
Write-Host $PathUpdateMessage
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Get started:"
|
||||
Write-Host " penguin --help # all commands"
|
||||
|
||||
+175
-43
@@ -6,6 +6,7 @@
|
||||
# Options:
|
||||
# PENGUIN_VERSION=vX.Y.Z pin a version (same as --version vX.Y.Z); default is the latest Release
|
||||
# PENGUIN_INSTALL_DIR=<dir> install dir; default ~/.penguin
|
||||
# PENGUIN_ARCHIVE=<file> install a local Release archive without network access (same as --archive <file>)
|
||||
# --universal install the universal package (no bundled Node runtime; needs system Node >= 24)
|
||||
#
|
||||
# The data dir (~/.penguin/data) sits under the install home but is never touched by reinstall/upgrade (which only replace bin/lib/web/node).
|
||||
@@ -18,6 +19,7 @@ VERSION="${PENGUIN_VERSION:-}"
|
||||
INSTALL_DIR="${PENGUIN_INSTALL_DIR:-$HOME/.penguin}"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
UNIVERSAL=0
|
||||
ARCHIVE="${PENGUIN_ARCHIVE:-}"
|
||||
|
||||
fail() {
|
||||
echo "error: $1" >&2
|
||||
@@ -36,6 +38,11 @@ while [ $# -gt 0 ]; do
|
||||
UNIVERSAL=1
|
||||
shift
|
||||
;;
|
||||
--archive)
|
||||
[ $# -ge 2 ] || fail "--archive requires a path to a Release archive"
|
||||
ARCHIVE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
fail "unknown option: $1"
|
||||
;;
|
||||
@@ -43,6 +50,7 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
|
||||
# --- Detect platform: Linux/Darwin x64/arm64; other platforms should use the universal package ---
|
||||
TARGET="universal"
|
||||
ASSET="penguin-universal.tar.gz"
|
||||
if [ "$UNIVERSAL" -eq 0 ]; then
|
||||
case "$(uname -s)" in
|
||||
@@ -55,7 +63,12 @@ if [ "$UNIVERSAL" -eq 0 ]; then
|
||||
aarch64 | arm64) arch="arm64" ;;
|
||||
*) fail "unsupported architecture: $(uname -m). Install Node.js >= 24, then re-run with --universal." ;;
|
||||
esac
|
||||
ASSET="penguin-$os-$arch.tar.gz"
|
||||
TARGET="$os-$arch"
|
||||
ASSET="penguin-$TARGET.tar.gz"
|
||||
fi
|
||||
|
||||
if [ -n "$ARCHIVE" ] && [ -n "$VERSION" ]; then
|
||||
fail "--archive/PENGUIN_ARCHIVE cannot be combined with --version/PENGUIN_VERSION"
|
||||
fi
|
||||
|
||||
# --- Universal package precheck: system Node >= 24 (platform packages bundle the runtime, so exempt) ---
|
||||
@@ -70,82 +83,201 @@ if [ "$UNIVERSAL" -eq 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Download (latest Release by default; PENGUIN_VERSION pins a version) ---
|
||||
if [ -n "$VERSION" ]; then
|
||||
BASE_URL="$REPO/releases/download/$VERSION"
|
||||
else
|
||||
BASE_URL="$REPO/releases/latest/download"
|
||||
fi
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
STAGING=""
|
||||
OLD_DIR=""
|
||||
SWAP_ACTIVE=0
|
||||
MOVED_OLD=""
|
||||
MOVED_NEW=""
|
||||
|
||||
echo "Downloading $BASE_URL/$ASSET ..."
|
||||
curl -fSL --progress-bar "$BASE_URL/$ASSET" -o "$TMP/$ASSET" \
|
||||
|| fail "download failed. Check the version tag and your network, then retry."
|
||||
rollback_install() {
|
||||
rollback_failed=0
|
||||
for d in $MOVED_NEW; do
|
||||
rm -rf "$INSTALL_DIR/$d" || rollback_failed=1
|
||||
done
|
||||
for d in $MOVED_OLD; do
|
||||
if [ -e "$OLD_DIR/$d" ]; then
|
||||
mv "$OLD_DIR/$d" "$INSTALL_DIR/$d" || rollback_failed=1
|
||||
fi
|
||||
done
|
||||
if [ "$rollback_failed" -ne 0 ]; then
|
||||
echo "error: automatic rollback was incomplete; previous files remain in $OLD_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Previous PenguinHarness installation restored." >&2
|
||||
}
|
||||
|
||||
# --- SHA256 verify: only when .sha256 exists (skip on 404); warn and skip if no checksum tool ---
|
||||
if curl -fsSL "$BASE_URL/$ASSET.sha256" -o "$TMP/$ASSET.sha256" 2>/dev/null; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$TMP" && sha256sum -c "$ASSET.sha256" >/dev/null 2>&1) || fail "checksum mismatch for $ASSET."
|
||||
echo "Checksum OK."
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$TMP" && shasum -a 256 -c "$ASSET.sha256" >/dev/null 2>&1) || fail "checksum mismatch for $ASSET."
|
||||
echo "Checksum OK."
|
||||
cleanup() {
|
||||
status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
set +e
|
||||
if [ "$SWAP_ACTIVE" -eq 1 ]; then
|
||||
rollback_install
|
||||
fi
|
||||
rm -rf "$TMP"
|
||||
[ -z "$STAGING" ] || rm -rf "$STAGING"
|
||||
[ -z "$OLD_DIR" ] || rm -rf "$OLD_DIR"
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# --- Resolve local/offline archive or download from GitHub. ---
|
||||
LOCAL_ARCHIVE=0
|
||||
HAVE_SHA=0
|
||||
if [ -n "$ARCHIVE" ]; then
|
||||
[ -f "$ARCHIVE" ] || fail "local archive not found: $ARCHIVE"
|
||||
archive_name="${ARCHIVE##*/}"
|
||||
archive_dir="$(CDPATH= cd "$(dirname "$ARCHIVE")" && pwd)"
|
||||
ARCHIVE_PATH="$archive_dir/$archive_name"
|
||||
SHA_PATH="$ARCHIVE_PATH.sha256"
|
||||
if [ ! -f "$SHA_PATH" ] && [ "$archive_name" != "$ASSET" ] && [ -f "$archive_dir/$ASSET.sha256" ]; then
|
||||
SHA_PATH="$archive_dir/$ASSET.sha256"
|
||||
fi
|
||||
[ -f "$SHA_PATH" ] || fail "offline checksum file not found: $SHA_PATH"
|
||||
ARCHIVE_NAME="$archive_name"
|
||||
LOCAL_ARCHIVE=1
|
||||
HAVE_SHA=1
|
||||
echo "Using local archive $ARCHIVE_PATH ..."
|
||||
else
|
||||
if [ -n "$VERSION" ]; then
|
||||
BASE_URL="$REPO/releases/download/$VERSION"
|
||||
else
|
||||
BASE_URL="$REPO/releases/latest/download"
|
||||
fi
|
||||
ARCHIVE_PATH="$TMP/$ASSET"
|
||||
SHA_PATH="$TMP/$ASSET.sha256"
|
||||
ARCHIVE_NAME="$ASSET"
|
||||
echo "Downloading $BASE_URL/$ASSET ..."
|
||||
curl -fSL --progress-bar "$BASE_URL/$ASSET" -o "$ARCHIVE_PATH" \
|
||||
|| fail "download failed. Check the version tag and your network, then retry."
|
||||
if curl -fsSL "$BASE_URL/$ASSET.sha256" -o "$SHA_PATH" 2>/dev/null; then
|
||||
HAVE_SHA=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- SHA256 verify: mandatory offline; online keeps the existing warn-and-skip fallback. ---
|
||||
if [ "$HAVE_SHA" -eq 1 ]; then
|
||||
expected="$(awk 'NR == 1 { print $1 }' "$SHA_PATH" | tr 'A-F' 'a-f')"
|
||||
[ -n "$expected" ] || fail "checksum file is empty or malformed: $SHA_PATH"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "$ARCHIVE_PATH" | awk '{ print $1 }')"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{ print $1 }')"
|
||||
else
|
||||
if [ "$LOCAL_ARCHIVE" -eq 1 ]; then
|
||||
fail "offline installation requires sha256sum or shasum for checksum verification"
|
||||
fi
|
||||
actual=""
|
||||
echo "warning: sha256sum/shasum not found; skipping checksum verification." >&2
|
||||
fi
|
||||
if [ -n "$actual" ]; then
|
||||
[ "$actual" = "$expected" ] || fail "checksum mismatch for $ASSET."
|
||||
echo "Checksum OK."
|
||||
fi
|
||||
else
|
||||
echo "warning: checksum file not available; skipping verification." >&2
|
||||
fi
|
||||
|
||||
# --- Extract and swap into place: first move the new dirs into a staging area inside the install
|
||||
# dir (same filesystem as the final location, so any slow cross-device copy happens before the
|
||||
# old install is touched), then swap fast (rm old + same-disk mv is a rename; tiny window).
|
||||
# No stale files after upgrade; the universal package has no node/, so cleanup lets the wrapper
|
||||
# fall back to system Node. The data dir (~/.penguin/data) is untouched. ---
|
||||
tar -xzf "$TMP/$ASSET" -C "$TMP"
|
||||
# --- Extract and validate in staging before touching the current install. The final same-filesystem
|
||||
# swap keeps the previous dirs in .old.$$ until the installed command runs successfully; any
|
||||
# move or launch failure restores them automatically. The data dir is never part of the swap. ---
|
||||
tar -xzf "$ARCHIVE_PATH" -C "$TMP"
|
||||
[ -d "$TMP/penguin" ] || fail "unexpected archive layout: top-level penguin/ missing."
|
||||
MANIFEST_PATH="$TMP/penguin/package-manifest.json"
|
||||
if [ -f "$MANIFEST_PATH" ]; then
|
||||
manifest_target="$(sed -n 's/.*"target"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_PATH" | head -n 1)"
|
||||
[ -n "$manifest_target" ] || fail "package manifest is malformed: target missing."
|
||||
[ "$manifest_target" = "$TARGET" ] \
|
||||
|| fail "package target mismatch: expected $TARGET, found $manifest_target."
|
||||
elif [ "$LOCAL_ARCHIVE" -eq 1 ] && [ "$ARCHIVE_NAME" != "$ASSET" ]; then
|
||||
fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
|
||||
fi
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
STAGING="$INSTALL_DIR/.staging.$$"
|
||||
OLD_DIR="$INSTALL_DIR/.old.$$"
|
||||
rm -rf "$STAGING"
|
||||
rm -rf "$OLD_DIR"
|
||||
mkdir -p "$STAGING"
|
||||
trap 'rm -rf "$TMP" "$STAGING"' EXIT
|
||||
for d in bin lib web node; do
|
||||
if [ -e "$TMP/penguin/$d" ]; then
|
||||
mv "$TMP/penguin/$d" "$STAGING/$d"
|
||||
fi
|
||||
done
|
||||
[ -x "$STAGING/bin/penguin" ] || fail "unexpected archive layout: bin/penguin missing."
|
||||
rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/lib" "$INSTALL_DIR/web" "$INSTALL_DIR/node"
|
||||
|
||||
# The launcher resolves lib/, web/ and node/ relative to itself, so staging is a faithful
|
||||
# preflight that catches macOS execution policy and runtime/package failures before replacement.
|
||||
if candidate_version="$("$STAGING/bin/penguin" --version)"; then
|
||||
[ -n "$candidate_version" ] || fail "candidate PenguinHarness returned an empty version."
|
||||
else
|
||||
candidate_status=$?
|
||||
fail "candidate PenguinHarness failed to run (exit status $candidate_status). See the error above."
|
||||
fi
|
||||
|
||||
mkdir -p "$OLD_DIR"
|
||||
SWAP_ACTIVE=1
|
||||
for d in bin lib web node; do
|
||||
if [ -e "$STAGING/$d" ]; then
|
||||
mv "$STAGING/$d" "$INSTALL_DIR/$d"
|
||||
if [ -e "$INSTALL_DIR/$d" ]; then
|
||||
if mv "$INSTALL_DIR/$d" "$OLD_DIR/$d"; then
|
||||
MOVED_OLD="$MOVED_OLD $d"
|
||||
else
|
||||
fail "could not move the existing $d directory aside; the previous installation will be restored."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
for d in bin lib web node; do
|
||||
if [ -e "$STAGING/$d" ]; then
|
||||
if mv "$STAGING/$d" "$INSTALL_DIR/$d"; then
|
||||
MOVED_NEW="$MOVED_NEW $d"
|
||||
else
|
||||
fail "could not install the new $d directory; the previous installation will be restored."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
rm -rf "$STAGING"
|
||||
[ -x "$INSTALL_DIR/bin/penguin" ] || fail "install incomplete: $INSTALL_DIR/bin/penguin missing."
|
||||
|
||||
# --- Symlink into ~/.local/bin and check PATH ---
|
||||
# Verify again from the final path before deleting the backup. Keep stderr visible so platform
|
||||
# policy, permission and runtime errors are not disguised as an "unknown" version.
|
||||
if installed_version="$("$INSTALL_DIR/bin/penguin" --version)"; then
|
||||
[ -n "$installed_version" ] || fail "installed PenguinHarness returned an empty version."
|
||||
else
|
||||
version_status=$?
|
||||
fail "installed PenguinHarness failed to run (exit status $version_status); the previous installation will be restored. See the error above."
|
||||
fi
|
||||
|
||||
SWAP_ACTIVE=0
|
||||
if ! rm -rf "$OLD_DIR"; then
|
||||
echo "warning: could not remove the previous installation backup at $OLD_DIR" >&2
|
||||
fi
|
||||
OLD_DIR=""
|
||||
rm -rf "$STAGING"
|
||||
STAGING=""
|
||||
|
||||
# --- Symlink into ~/.local/bin and check PATH only after the install is known to work. ---
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$INSTALL_DIR/bin/penguin" "$BIN_DIR/penguin"
|
||||
PATH_MISSING=0
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) ;;
|
||||
*)
|
||||
echo ""
|
||||
echo "note: $BIN_DIR is not on your PATH. Add it to your shell profile:"
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
|
||||
*/bash) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
|
||||
*/fish) echo " fish_add_path \$HOME/.local/bin" ;;
|
||||
*) echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
|
||||
esac
|
||||
;;
|
||||
*) PATH_MISSING=1 ;;
|
||||
esac
|
||||
|
||||
# --- Finish: print version and getting-started tips ---
|
||||
installed_version="$("$INSTALL_DIR/bin/penguin" --version 2>/dev/null || echo "unknown")"
|
||||
echo ""
|
||||
echo "PenguinHarness $installed_version installed to $INSTALL_DIR"
|
||||
if [ "$PATH_MISSING" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "note: installation succeeded, but $BIN_DIR is not on your PATH. Add it to your shell profile:"
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
|
||||
*/bash) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
|
||||
*/fish) echo " fish_add_path \$HOME/.local/bin" ;;
|
||||
*) echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
|
||||
esac
|
||||
fi
|
||||
echo ""
|
||||
echo "Get started:"
|
||||
echo " penguin --help # all commands"
|
||||
|
||||
@@ -37,6 +37,24 @@ Verify the install:
|
||||
penguin -v
|
||||
```
|
||||
|
||||
### Offline bundles
|
||||
|
||||
Each Release also publishes self-contained offline bundles for Windows x64, Linux x64/arm64 and macOS x64/arm64. Download the bundle matching the target computer on a connected machine, transfer it, then extract it once.
|
||||
|
||||
On Windows, double-click `install.cmd`, or run:
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -ArchivePath .\penguin-win32-x64.zip
|
||||
```
|
||||
|
||||
On Linux / macOS, run:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The extracted bundle keeps the matching program archive, its `.sha256` file and an offline entry point together. The bundle's `install.sh` passes that archive explicitly to the real installer, requires a successful checksum verification and performs no network requests. You can also use the separately published Release installer with an explicit local path: `install.sh --archive <file>`, `PENGUIN_ARCHIVE=<file>`, `install.ps1 -ArchivePath <file>`, or `$env:PENGUIN_ARCHIVE`.
|
||||
|
||||
### Install location and options
|
||||
|
||||
| Item | Details |
|
||||
@@ -44,6 +62,7 @@ penguin -v
|
||||
| Install dir | `~/.penguin` by default; override with the `PENGUIN_INSTALL_DIR` env var |
|
||||
| Command entry | A symlink `~/.local/bin/penguin` is created (the script warns if `~/.local/bin` is not on PATH) |
|
||||
| Version pin | `PENGUIN_VERSION=vX.Y.Z` env var, or the `--version vX.Y.Z` script flag; defaults to the latest Release |
|
||||
| Local archive | `PENGUIN_ARCHIVE=<file>` or `--archive <file>`; renamed files are accepted with an adjacent `<file>.sha256` or the platform asset's canonical `.sha256` |
|
||||
| Integrity check | Downloads are sha256-verified when the Release ships checksum assets |
|
||||
| Upgrade | Re-run the install script; files are swapped atomically |
|
||||
|
||||
@@ -56,6 +75,7 @@ Script flags go after `sh -s --`, e.g. `curl -fsSL https://penguin.ooo/install.s
|
||||
| Install dir | `%USERPROFILE%\.penguin` by default; override with the `PENGUIN_INSTALL_DIR` env var |
|
||||
| Command entry | `bin\penguin.cmd` and `bin\penguin.ps1` launchers; the installer adds `%USERPROFILE%\.penguin\bin` to your **user** Path (restart the terminal once) |
|
||||
| Version pin | `$env:PENGUIN_VERSION = "vX.Y.Z"` before running the installer |
|
||||
| Local archive | `$env:PENGUIN_ARCHIVE = "<file>"` or `-ArchivePath <file>`; renamed files are accepted with an adjacent `<file>.sha256` or `penguin-win32-x64.zip.sha256` |
|
||||
| Integrity check | Downloads are sha256-verified when the Release ships checksum assets |
|
||||
| Upgrade | Re-run the installer; it swaps `bin`/`lib`/`web`/`node` and never touches `data` |
|
||||
|
||||
|
||||
@@ -37,6 +37,24 @@ $env:PENGUIN_VERSION = "vX.Y.Z"; irm https://penguin.ooo/install.ps1 | iex
|
||||
penguin -v
|
||||
```
|
||||
|
||||
### 离线安装包
|
||||
|
||||
每个 Release 还会分别提供 Windows x64、Linux x64/arm64 与 macOS x64/arm64 的完整离线包。先在可联网电脑上下载与目标电脑匹配的离线包,传输到目标电脑后解压一次。
|
||||
|
||||
Windows 上双击 `install.cmd`,或执行:
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -ArchivePath .\penguin-win32-x64.zip
|
||||
```
|
||||
|
||||
Linux / macOS 上执行:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
解压后的目录同时包含对应平台的程序压缩包、`.sha256` 文件和离线安装入口。离线包内的 `install.sh` 会将同包内的程序压缩包显式传给实际安装器,强制完成 checksum 校验,并且不会发起任何网络请求。也可以使用 Release 中单独发布的安装器显式指定本地文件:`install.sh --archive <file>`、`PENGUIN_ARCHIVE=<file>`、`install.ps1 -ArchivePath <file>` 或 `$env:PENGUIN_ARCHIVE`。
|
||||
|
||||
### 安装位置与选项
|
||||
|
||||
| 项目 | 说明 |
|
||||
@@ -44,6 +62,7 @@ penguin -v
|
||||
| 安装目录 | 默认 `~/.penguin`,可用环境变量 `PENGUIN_INSTALL_DIR` 覆盖 |
|
||||
| 命令入口 | 创建符号链接 `~/.local/bin/penguin`(若 `~/.local/bin` 不在 PATH 上,脚本会给出提示) |
|
||||
| 版本固定 | 环境变量 `PENGUIN_VERSION=vX.Y.Z`,或脚本参数 `--version vX.Y.Z`;默认安装最新 Release |
|
||||
| 本地压缩包 | `PENGUIN_ARCHIVE=<file>` 或 `--archive <file>`;允许重命名,要求旁边存在 `<file>.sha256` 或平台标准名称的 `.sha256` |
|
||||
| 完整性校验 | Release 提供 checksum 资产时自动进行 sha256 校验 |
|
||||
| 升级 | 重新执行安装脚本即可,文件原子替换 |
|
||||
|
||||
@@ -56,6 +75,7 @@ penguin -v
|
||||
| 安装目录 | 默认 `%USERPROFILE%\.penguin`,可用环境变量 `PENGUIN_INSTALL_DIR` 覆盖 |
|
||||
| 命令入口 | `bin\penguin.cmd` 与 `bin\penguin.ps1` 启动器;安装器会把 `%USERPROFILE%\.penguin\bin` 加入**用户** Path(重启终端后生效) |
|
||||
| 版本固定 | 运行安装器前设置 `$env:PENGUIN_VERSION = "vX.Y.Z"` |
|
||||
| 本地压缩包 | `$env:PENGUIN_ARCHIVE = "<file>"` 或 `-ArchivePath <file>`;允许重命名,要求旁边存在 `<file>.sha256` 或 `penguin-win32-x64.zip.sha256` |
|
||||
| 完整性校验 | Release 提供 checksum 资产时自动进行 sha256 校验 |
|
||||
| 升级 | 重新运行安装器;只替换 `bin`/`lib`/`web`/`node`,绝不触碰 `data` |
|
||||
|
||||
|
||||
Generated
+73
-5
@@ -3,6 +3,7 @@ lockfileVersion: '9.0'
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
injectWorkspacePackages: true
|
||||
|
||||
overrides:
|
||||
'@anthropic-ai/sdk': ^0.91.1
|
||||
@@ -14,10 +15,10 @@ importers:
|
||||
devDependencies:
|
||||
'@prismshadow/penguin-cli':
|
||||
specifier: workspace:*
|
||||
version: link:packages/cli
|
||||
version: file:packages/cli
|
||||
'@prismshadow/penguin-core':
|
||||
specifier: workspace:*
|
||||
version: link:packages/core
|
||||
version: file:packages/core(ws@8.21.0)
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.13.3
|
||||
@@ -47,7 +48,7 @@ importers:
|
||||
version: link:../core
|
||||
'@prismshadow/penguin-server':
|
||||
specifier: workspace:*
|
||||
version: link:../server
|
||||
version: file:packages/server(ws@8.21.0)
|
||||
'@prismshadow/penguin-skills':
|
||||
specifier: workspace:*
|
||||
version: link:../skills
|
||||
@@ -216,7 +217,7 @@ importers:
|
||||
version: 2.0.12(hono@4.12.28)
|
||||
'@prismshadow/penguin-core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
version: file:packages/core(ws@8.21.0)
|
||||
'@prismshadow/penguin-skills':
|
||||
specifier: workspace:*
|
||||
version: link:../skills
|
||||
@@ -271,7 +272,7 @@ importers:
|
||||
dependencies:
|
||||
'@prismshadow/penguin-core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
version: file:packages/core(ws@8.21.0)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.7
|
||||
@@ -756,6 +757,21 @@ packages:
|
||||
'@prismshadow/agenthub@0.4.1':
|
||||
resolution: {integrity: sha512-YKidCwa4ZO0+BP5E+vg/p8HQAPrTvTDaFmoj5omUHi7OxXCXPMDxwodj4VPz/ntWMDsgjsePhzkZIfVmSbECkw==}
|
||||
|
||||
'@prismshadow/penguin-cli@file:packages/cli':
|
||||
resolution: {directory: packages/cli, type: directory}
|
||||
engines: {node: '>=24'}
|
||||
hasBin: true
|
||||
|
||||
'@prismshadow/penguin-core@file:packages/core':
|
||||
resolution: {directory: packages/core, type: directory}
|
||||
|
||||
'@prismshadow/penguin-server@file:packages/server':
|
||||
resolution: {directory: packages/server, type: directory}
|
||||
engines: {node: '>=24'}
|
||||
|
||||
'@prismshadow/penguin-skills@file:packages/skills':
|
||||
resolution: {directory: packages/skills, type: directory}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
@@ -3257,6 +3273,58 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@prismshadow/penguin-cli@file:packages/cli':
|
||||
dependencies:
|
||||
'@prismshadow/agenthub': 0.4.1(ws@8.21.0)
|
||||
'@prismshadow/penguin-core': file:packages/core(ws@8.21.0)
|
||||
'@prismshadow/penguin-server': file:packages/server(ws@8.21.0)
|
||||
'@prismshadow/penguin-skills': file:packages/skills
|
||||
commander: 13.1.0
|
||||
dotenv: 17.4.2
|
||||
smol-toml: 1.6.1
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@prismshadow/penguin-core@file:packages/core(ws@8.21.0)':
|
||||
dependencies:
|
||||
'@prismshadow/agenthub': 0.4.1(ws@8.21.0)
|
||||
'@prismshadow/penguin-skills': file:packages/skills
|
||||
smol-toml: 1.6.1
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@prismshadow/penguin-server@file:packages/server(ws@8.21.0)':
|
||||
dependencies:
|
||||
'@hono/node-server': 2.0.12(hono@4.12.28)
|
||||
'@prismshadow/penguin-core': file:packages/core(ws@8.21.0)
|
||||
'@prismshadow/penguin-skills': file:packages/skills
|
||||
dotenv: 17.4.2
|
||||
hono: 4.12.28
|
||||
smol-toml: 1.6.1
|
||||
tar: 7.5.22
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@prismshadow/penguin-skills@file:packages/skills': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
injectWorkspacePackages: true
|
||||
syncInjectedDepsAfterScripts:
|
||||
- build
|
||||
allowBuilds:
|
||||
"@google/genai": true
|
||||
esbuild: true
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/sh
|
||||
# Wrap each platform-specific Release archive with the matching installer and checksum.
|
||||
# Relative artifact paths are resolved from the repository root.
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
|
||||
ARTIFACT_INPUT="${1:-dist-artifacts}"
|
||||
|
||||
case "$ARTIFACT_INPUT" in
|
||||
/*) ARTIFACT_DIR="$ARTIFACT_INPUT" ;;
|
||||
*) ARTIFACT_DIR="$ROOT_DIR/$ARTIFACT_INPUT" ;;
|
||||
esac
|
||||
|
||||
[ -d "$ARTIFACT_DIR" ] || {
|
||||
echo "error: artifact directory not found: $ARTIFACT_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v tar >/dev/null 2>&1 || {
|
||||
echo "error: tar is required" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v zip >/dev/null 2>&1 || {
|
||||
echo "error: zip is required" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
write_sha256() {
|
||||
file="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$(dirname "$file")" && sha256sum "$(basename "$file")" > "$(basename "$file").sha256")
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$(dirname "$file")" && shasum -a 256 "$(basename "$file")" > "$(basename "$file").sha256")
|
||||
else
|
||||
echo "error: sha256sum or shasum is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_payload() {
|
||||
payload="$1"
|
||||
[ -f "$ARTIFACT_DIR/$payload" ] || {
|
||||
echo "error: missing payload: $ARTIFACT_DIR/$payload" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -f "$ARTIFACT_DIR/$payload.sha256" ] || {
|
||||
echo "error: missing payload checksum: $ARTIFACT_DIR/$payload.sha256" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
|
||||
payload="penguin-$target.tar.gz"
|
||||
output="$ARTIFACT_DIR/penguin-$target-offline.tar.gz"
|
||||
bundle="$WORK_DIR/$target"
|
||||
require_payload "$payload"
|
||||
mkdir -p "$bundle"
|
||||
cp "$ARTIFACT_DIR/$payload" "$ARTIFACT_DIR/$payload.sha256" "$bundle/"
|
||||
cp "$ROOT_DIR/install.sh" "$bundle/penguin-installer.sh"
|
||||
{
|
||||
printf '%s\n' '#!/bin/sh'
|
||||
printf '%s\n' '# Offline bundle entry point. The payload path is explicit so the online installer never scans its directory.'
|
||||
printf '%s\n' 'set -eu'
|
||||
printf '%s\n' 'SCRIPT_DIR="$(CDPATH= cd "$(dirname "$0")" && pwd)"'
|
||||
printf 'exec sh "$SCRIPT_DIR/penguin-installer.sh" --archive "$SCRIPT_DIR/%s" "$@"\n' "$payload"
|
||||
} > "$bundle/install.sh"
|
||||
chmod +x "$bundle/install.sh" "$bundle/penguin-installer.sh"
|
||||
rm -f "$output" "$output.sha256"
|
||||
tar -czf "$output" -C "$bundle" .
|
||||
write_sha256 "$output"
|
||||
echo "Created $(basename "$output")"
|
||||
done
|
||||
|
||||
payload="penguin-win32-x64.zip"
|
||||
output="$ARTIFACT_DIR/penguin-win32-x64-offline.zip"
|
||||
bundle="$WORK_DIR/win32-x64"
|
||||
require_payload "$payload"
|
||||
mkdir -p "$bundle"
|
||||
cp "$ARTIFACT_DIR/$payload" "$ARTIFACT_DIR/$payload.sha256" \
|
||||
"$ROOT_DIR/install.ps1" "$ROOT_DIR/install.cmd" "$bundle/"
|
||||
rm -f "$output" "$output.sha256"
|
||||
(cd "$bundle" && zip -qr "$output" .)
|
||||
write_sha256 "$output"
|
||||
echo "Created $(basename "$output")"
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
# Smoke-test five offline wrappers and POSIX upgrade rollback with tiny fixtures.
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
ARTIFACT_DIR="$WORK_DIR/artifacts"
|
||||
TEST_HOME="$WORK_DIR/home"
|
||||
INSTALL_DIR="$TEST_HOME/.penguin"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT HUP INT TERM
|
||||
|
||||
fail_test() {
|
||||
echo "test failure: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
write_sha256() {
|
||||
file="$1"
|
||||
(cd "$(dirname "$file")" && sha256sum "$(basename "$file")" > "$(basename "$file").sha256")
|
||||
}
|
||||
|
||||
make_posix_payload() {
|
||||
target="$1"
|
||||
output="$2"
|
||||
behavior="${3:-success}"
|
||||
payload="$WORK_DIR/payload"
|
||||
rm -rf "$payload"
|
||||
mkdir -p "$payload/penguin/bin" "$payload/penguin/lib" "$payload/penguin/web"
|
||||
if [ "$behavior" = "final-failure" ]; then
|
||||
{
|
||||
printf '%s\n' '#!/bin/sh'
|
||||
printf '%s\n' 'case "$0" in'
|
||||
printf '%s\n' ' */.staging.*/bin/penguin) echo fixture-new; exit 0 ;;'
|
||||
printf '%s\n' ' *) echo "fixture final-path failure" >&2; exit 42 ;;'
|
||||
printf '%s\n' 'esac'
|
||||
} > "$payload/penguin/bin/penguin"
|
||||
else
|
||||
{
|
||||
printf '%s\n' '#!/bin/sh'
|
||||
printf '%s\n' 'echo fixture-old'
|
||||
} > "$payload/penguin/bin/penguin"
|
||||
fi
|
||||
chmod +x "$payload/penguin/bin/penguin"
|
||||
printf '%s\n' fixture > "$payload/penguin/lib/fixture.txt"
|
||||
printf '%s\n' fixture > "$payload/penguin/web/index.html"
|
||||
printf '{"schemaVersion":1,"target":"%s"}\n' "$target" > "$payload/penguin/package-manifest.json"
|
||||
tar -czf "$output" -C "$payload" penguin
|
||||
write_sha256 "$output"
|
||||
}
|
||||
|
||||
verify_bundle() {
|
||||
bundle="$1"
|
||||
shift
|
||||
[ -f "$bundle" ] || fail_test "missing $(basename "$bundle")"
|
||||
[ -f "$bundle.sha256" ] || fail_test "missing $(basename "$bundle").sha256"
|
||||
(cd "$(dirname "$bundle")" && sha256sum -c "$(basename "$bundle").sha256" >/dev/null)
|
||||
members="$("$@" | sed 's#^\./##' | sed '/^$/d')"
|
||||
count="$(printf '%s\n' "$members" | wc -l | tr -d ' ')"
|
||||
[ "$count" -eq 4 ] || fail_test "$(basename "$bundle") should contain exactly four files"
|
||||
}
|
||||
|
||||
command -v sha256sum >/dev/null 2>&1 || fail_test "sha256sum is required"
|
||||
command -v unzip >/dev/null 2>&1 || fail_test "unzip is required"
|
||||
mkdir -p "$ARTIFACT_DIR" "$TEST_HOME"
|
||||
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
|
||||
make_posix_payload "$target" "$ARTIFACT_DIR/penguin-$target.tar.gz"
|
||||
done
|
||||
|
||||
windows_payload="$WORK_DIR/windows/penguin"
|
||||
mkdir -p "$windows_payload/bin"
|
||||
printf '%s\r\n' '@echo off' 'echo fixture-old' > "$windows_payload/bin/penguin.cmd"
|
||||
printf '%s\n' '{"schemaVersion":1,"target":"win32-x64"}' > "$windows_payload/package-manifest.json"
|
||||
(cd "$WORK_DIR/windows" && zip -qr "$ARTIFACT_DIR/penguin-win32-x64.zip" penguin)
|
||||
write_sha256 "$ARTIFACT_DIR/penguin-win32-x64.zip"
|
||||
|
||||
sh "$ROOT_DIR/scripts/package-offline-bundles.sh" "$ARTIFACT_DIR"
|
||||
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
|
||||
bundle="$ARTIFACT_DIR/penguin-$target-offline.tar.gz"
|
||||
verify_bundle "$bundle" tar -tzf "$bundle"
|
||||
tar -tzf "$bundle" | grep -q "penguin-$target.tar.gz.sha256" \
|
||||
|| fail_test "$(basename "$bundle") is missing its payload checksum"
|
||||
done
|
||||
|
||||
windows_bundle="$ARTIFACT_DIR/penguin-win32-x64-offline.zip"
|
||||
verify_bundle "$windows_bundle" unzip -Z1 "$windows_bundle"
|
||||
unzip -Z1 "$windows_bundle" | grep -q "penguin-win32-x64.zip.sha256" \
|
||||
|| fail_test "Windows bundle is missing its payload checksum"
|
||||
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$INSTALL_DIR" \
|
||||
sh "$ROOT_DIR/install.sh" --archive "$ARTIFACT_DIR/penguin-linux-x64.tar.gz" >/dev/null
|
||||
|
||||
failure_archive="$WORK_DIR/final-failure.tar.gz"
|
||||
make_posix_payload linux-x64 "$failure_archive" final-failure
|
||||
set +e
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$INSTALL_DIR" \
|
||||
sh "$ROOT_DIR/install.sh" --archive "$failure_archive" >/dev/null 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[ "$status" -ne 0 ] || fail_test "failing POSIX upgrade unexpectedly succeeded"
|
||||
[ "$("$INSTALL_DIR/bin/penguin" --version)" = "fixture-old" ] \
|
||||
|| fail_test "previous POSIX installation was not restored"
|
||||
|
||||
echo "Offline bundle and POSIX rollback smoke tests passed."
|
||||
@@ -0,0 +1,65 @@
|
||||
# Smoke-test Windows offline upgrade rollback with tiny local archives.
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$Installer = Join-Path $RepoRoot "install.ps1"
|
||||
$WorkDir = Join-Path ([IO.Path]::GetTempPath()) "penguin-offline-install-tests-$PID"
|
||||
$OriginalPath = $env:Path
|
||||
$OriginalOs = $env:OS
|
||||
|
||||
function Assert-True([bool]$Condition, [string]$Message) {
|
||||
if (-not $Condition) { throw "test failure: $Message" }
|
||||
}
|
||||
|
||||
function New-FixtureArchive([string]$Name, [bool]$Fails = $false) {
|
||||
$SourceDir = Join-Path $WorkDir "$Name-source"
|
||||
$PenguinDir = Join-Path $SourceDir "penguin"
|
||||
New-Item -ItemType Directory -Path (Join-Path $PenguinDir "bin") -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $PenguinDir "lib") -Force | Out-Null
|
||||
$VersionLines = if ($Fails) {
|
||||
@("echo fixture runtime failure 1>&2", "exit /b 42")
|
||||
} else {
|
||||
@("echo fixture-old", "exit /b 0")
|
||||
}
|
||||
@("@echo off", "if `"%~1`"==`"--version`" (") + $VersionLines + @(")", "exit /b 0") |
|
||||
Set-Content -LiteralPath (Join-Path $PenguinDir "bin\penguin.cmd") -Encoding ascii
|
||||
"fixture" | Set-Content -LiteralPath (Join-Path $PenguinDir "lib\fixture.txt") -Encoding ascii
|
||||
@{ schemaVersion = 1; target = "win32-x64" } | ConvertTo-Json -Compress |
|
||||
Set-Content -LiteralPath (Join-Path $PenguinDir "package-manifest.json") -Encoding ascii
|
||||
$Archive = Join-Path $WorkDir "$Name.zip"
|
||||
Compress-Archive -Path $PenguinDir -DestinationPath $Archive -CompressionLevel Fastest
|
||||
$Hash = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash
|
||||
"$Hash $([IO.Path]::GetFileName($Archive))" |
|
||||
Set-Content -LiteralPath "$Archive.sha256" -Encoding ascii
|
||||
return $Archive
|
||||
}
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
|
||||
# Keep the fixture test away from the runner's user registry Path.
|
||||
$env:OS = "PenguinInstallerFixtureTest"
|
||||
$InstallDir = Join-Path $WorkDir "installed"
|
||||
$GoodArchive = New-FixtureArchive "valid"
|
||||
& $Installer -InstallDir $InstallDir -ArchivePath $GoodArchive *>&1 | Out-Null
|
||||
|
||||
$FailedArchive = New-FixtureArchive "failure" $true
|
||||
$Failed = $false
|
||||
try {
|
||||
& $Installer -InstallDir $InstallDir -ArchivePath $FailedArchive *>&1 | Out-Null
|
||||
} catch {
|
||||
$Failed = $true
|
||||
}
|
||||
Assert-True $Failed "failing Windows upgrade unexpectedly succeeded"
|
||||
$Version = & (Join-Path $InstallDir "bin\penguin.cmd") --version
|
||||
Assert-True ($Version -eq "fixture-old") "previous Windows installation was not restored"
|
||||
Write-Host "Windows offline rollback smoke test passed."
|
||||
} finally {
|
||||
$env:Path = $OriginalPath
|
||||
$env:OS = $OriginalOs
|
||||
if (Test-Path -LiteralPath $WorkDir) {
|
||||
Remove-Item -LiteralPath $WorkDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user