fix(release): harden cross-platform offline installers (#131)

This commit is contained in:
laodouuu
2026-07-30 16:02:25 +08:00
committed by GitHub
parent 081fe2d172
commit de6380f278
14 changed files with 786 additions and 131 deletions
+1
View File
@@ -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
+7
View File
@@ -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
+20 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+20
View File
@@ -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` |
+20
View File
@@ -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` |
+73 -5
View File
@@ -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': {}
+3
View File
@@ -1,5 +1,8 @@
packages:
- "packages/*"
injectWorkspacePackages: true
syncInjectedDepsAfterScripts:
- build
allowBuilds:
"@google/genai": true
esbuild: true
+87
View File
@@ -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")"
+105
View File
@@ -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."
+65
View File
@@ -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
}
}