feat(installer,web): one canonical bundle per target; robust in-place upgrades; update-check feedback (#142)
Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,8 @@ jobs:
|
||||
- name: Unit tests (vitest)
|
||||
run: pnpm test
|
||||
|
||||
- name: Offline bundle and POSIX installer tests
|
||||
run: sh scripts/test-offline-bundles.sh
|
||||
- name: Installer bundle and POSIX installer tests
|
||||
run: sh scripts/test-installer.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).
|
||||
@@ -112,6 +112,6 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- name: Windows offline installer tests
|
||||
- name: Windows installer tests
|
||||
shell: pwsh
|
||||
run: ./scripts/test-offline-install.ps1
|
||||
run: ./scripts/test-installer.ps1
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
# already-released tag skips the build/upload entirely and only re-runs npm publishing.
|
||||
# Two parallel jobs (the release job is gated on the existence check):
|
||||
# - release: build the monorepo -> pnpm deploy a production CLI dir -> assemble penguin/ (bin + lib + web)
|
||||
# -> five platform packages each bundling the official Node runtime + a universal package -> SHA256 files -> upload to the Release.
|
||||
# Artifacts: penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-win32-x64.zip, penguin-universal.tar.gz,
|
||||
# their .sha256 files, SHA256SUMS, install.sh, and install.ps1; one version per tag, multiple versions coexist.
|
||||
# -> one program payload per target (four platform payloads bundling the official Node runtime,
|
||||
# a win-x64 payload with runtime + MinGit, and a runtime-less universal payload) -> wrap each
|
||||
# payload, its checksum and the native installer into the canonical installer bundle -> validate
|
||||
# the real bundles -> upload to the Release.
|
||||
# Artifacts (one shape per target, serving online and offline installs alike):
|
||||
# penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-universal.tar.gz, penguin-win32-x64.zip,
|
||||
# their .sha256 files, SHA256SUMS, install.sh, and install.ps1; one version per tag, multiple
|
||||
# versions coexist. Releases up to v0.1.5 shipped raw program archives under the same names
|
||||
# plus *-offline wrappers; the installers keep accepting that legacy layout for pinned versions.
|
||||
# - publish-npm: publish the whole chain (@prismshadow/penguin-skills -> @prismshadow/penguin-core
|
||||
# -> @prismshadow/penguin-server -> @prismshadow/penguin-cli) to npm at the tag version.
|
||||
# skills/core serve the penguin-sdk Skill's `npm install`; server ships the built web assets inside
|
||||
@@ -136,11 +142,13 @@ jobs:
|
||||
EOF
|
||||
chmod +x out/penguin/bin/penguin
|
||||
|
||||
# Platform packages: linux uses .tar.xz, darwin uses .tar.gz (nodejs.org naming);
|
||||
# node/ is only lightly trimmed (drop share/doc and share/man, keep the rest).
|
||||
- name: Package platform + universal tarballs
|
||||
# Program payloads: linux runtimes use .tar.xz, darwin .tar.gz (nodejs.org naming);
|
||||
# node/ is only lightly trimmed (drop share/doc and share/man, keep the rest). Payloads
|
||||
# are intermediate files named <target>.tar.gz / win32-x64.zip: the packaging script
|
||||
# below seals each one into the canonical installer bundle that gets published.
|
||||
- name: Package platform + universal payloads
|
||||
run: |
|
||||
mkdir -p dist-artifacts
|
||||
mkdir -p payloads
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
|
||||
os="${target%%-*}"
|
||||
arch="${target#*-}"
|
||||
@@ -157,19 +165,22 @@ jobs:
|
||||
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
|
||||
tar -czf "payloads/$os-$arch.tar.gz" -C out penguin
|
||||
done
|
||||
# Universal package: no bundled runtime, requires system Node >= 24.
|
||||
# Universal payload: 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
|
||||
tar -czf payloads/universal.tar.gz -C out penguin
|
||||
|
||||
# Windows package: same lib/ + web/ layout, but a .zip (the native format), the official
|
||||
# win-x64 Node runtime — whose zip has node.exe at the ARCHIVE ROOT, not bin/ — and
|
||||
# cmd/ps1 launchers replacing the sh one (resolve their own dir, default PENGUIN_WEB_DIST
|
||||
# to the sibling web\, prefer the bundled node\node.exe, fall back to system node).
|
||||
# install.ps1 verifies and unpacks this zip; in PowerShell the .ps1 shim wins over .cmd,
|
||||
# in cmd.exe only the .cmd is found — both forward all args and the exit code.
|
||||
# Windows payload: same lib/ + web/ layout, but a .zip (the native format), the official
|
||||
# win-x64 Node runtime — whose zip has node.exe at the ARCHIVE ROOT, not bin/ — and a
|
||||
# cmd launcher replacing the sh one (resolves its own dir, defaults PENGUIN_WEB_DIST to
|
||||
# the sibling web\, prefers the bundled node\node.exe, falls back to system node).
|
||||
# Deliberately NO penguin.ps1 launcher: PowerShell prefers .ps1 over .cmd on PATH, and
|
||||
# client Windows defaults to the Restricted execution policy, so shipping one makes the
|
||||
# plain `penguin` command fail with "running scripts is disabled" out of the box. Batch
|
||||
# files are exempt from the policy and both PowerShell and cmd.exe resolve penguin.cmd,
|
||||
# which forwards all args and the exit code.
|
||||
#
|
||||
# It also bundles MinGit under git\, so exec_command has a POSIX shell even on a machine
|
||||
# with no Git for Windows: the shims advertise git\usr\bin\sh.exe as PENGUIN_BUNDLED_SHELL
|
||||
@@ -177,7 +188,7 @@ jobs:
|
||||
# MinGit is unpacked with its tree intact — MSYS binaries locate /etc relative to the
|
||||
# directory holding msys-2.0.dll, so `sh -lc` finds git\etc\profile and gets the usual
|
||||
# /mingw64/bin:/usr/bin:<inherited Windows PATH>, keeping System32's curl/tar reachable.
|
||||
- name: Package win-x64 zip
|
||||
- name: Package win-x64 payload
|
||||
run: |
|
||||
name="node-$NODE_RUNTIME_VERSION-win-x64"
|
||||
curl -fsSL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$name.zip" -o "/tmp/$name.zip"
|
||||
@@ -211,35 +222,59 @@ jobs:
|
||||
EOF
|
||||
# cmd.exe is only fully reliable with CRLF batch files.
|
||||
sed -i 's/$/\r/' out/penguin/bin/penguin.cmd
|
||||
cat > out/penguin/bin/penguin.ps1 <<'EOF'
|
||||
$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
|
||||
EOF
|
||||
# PowerShell accepts LF, but ship CRLF like the .cmd (and the repo's *.ps1 eol=crlf attribute).
|
||||
sed -i 's/$/\r/' out/penguin/bin/penguin.ps1
|
||||
(cd out && zip -qr ../dist-artifacts/penguin-win32-x64.zip penguin)
|
||||
(cd out && zip -qr ../payloads/win32-x64.zip penguin)
|
||||
|
||||
# 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
|
||||
# The canonical artifacts: each payload is sealed with its checksum and the native
|
||||
# installer into one flat bundle per target (see scripts/package-release-bundles.sh).
|
||||
- name: Package canonical installer bundles
|
||||
run: sh scripts/package-release-bundles.sh payloads dist-artifacts
|
||||
|
||||
# Validate the real release outputs, not only the small fixtures used by the script tests.
|
||||
# Every bundle must pass its outer checksum, stay flat with exactly the documented member
|
||||
# set, carry a byte-identical installer and payload, and pass its sealed payload checksum.
|
||||
- name: Validate canonical installer bundles
|
||||
run: |
|
||||
cd dist-artifacts
|
||||
for f in *.tar.gz *.zip; do
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
set -eu
|
||||
ARTIFACT_DIR="$PWD/dist-artifacts"
|
||||
PAYLOAD_DIR="$PWD/payloads"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
validate_layout() {
|
||||
dir="$1"
|
||||
expected="$2"
|
||||
actual="$(find "$dir" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)"
|
||||
[ -z "$(find "$dir" -mindepth 2 -print -quit)" ]
|
||||
[ "$actual" = "$expected" ]
|
||||
}
|
||||
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 universal; do
|
||||
bundle="penguin-$target.tar.gz"
|
||||
(cd "$ARTIFACT_DIR" && sha256sum -c "$bundle.sha256")
|
||||
extracted="$WORK_DIR/$target"
|
||||
mkdir -p "$extracted"
|
||||
tar -xzf "$ARTIFACT_DIR/$bundle" -C "$extracted"
|
||||
expected="$(printf '%s\n' install.sh payload.tar.gz payload.tar.gz.sha256 | LC_ALL=C sort)"
|
||||
validate_layout "$extracted" "$expected"
|
||||
[ -x "$extracted/install.sh" ]
|
||||
(cd "$extracted" && sha256sum -c payload.tar.gz.sha256)
|
||||
cmp "$PWD/install.sh" "$extracted/install.sh"
|
||||
cmp "$PAYLOAD_DIR/$target.tar.gz" "$extracted/payload.tar.gz"
|
||||
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
|
||||
bundle="penguin-win32-x64.zip"
|
||||
(cd "$ARTIFACT_DIR" && sha256sum -c "$bundle.sha256")
|
||||
extracted="$WORK_DIR/win32-x64"
|
||||
mkdir -p "$extracted"
|
||||
unzip -q "$ARTIFACT_DIR/$bundle" -d "$extracted"
|
||||
expected="$(printf '%s\n' install.cmd install.ps1 payload.zip payload.zip.sha256 | LC_ALL=C sort)"
|
||||
validate_layout "$extracted" "$expected"
|
||||
(cd "$extracted" && sha256sum -c payload.zip.sha256)
|
||||
cmp "$PWD/install.cmd" "$extracted/install.cmd"
|
||||
cmp "$PWD/install.ps1" "$extracted/install.ps1"
|
||||
cmp "$PAYLOAD_DIR/win32-x64.zip" "$extracted/payload.zip"
|
||||
|
||||
# Summary covers both raw program archives and their offline installer wrappers.
|
||||
# Summary covers the six canonical bundles.
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
cd dist-artifacts
|
||||
|
||||
@@ -139,31 +139,31 @@ penguin web # start the service and open http://127.0.0.1:7364
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>📴 Offline install packages (air-gapped machines)</b></summary>
|
||||
<summary><b>📴 Offline install (air-gapped machines)</b></summary>
|
||||
|
||||
Every <a href="https://github.com/Prism-Shadow/penguin-harness/releases">GitHub Release</a> attaches five self-contained offline bundles — Linux and macOS in x64 / arm64, Windows in x64. Each bundle carries the program archive, its SHA256 checksum and the platform's installer: download on a networked machine, copy to the target, and install with no network at all (offline installs verify the SHA256 unconditionally).
|
||||
Every <a href="https://github.com/Prism-Shadow/penguin-harness/releases">GitHub Release</a> attaches exactly one package per target — Linux and macOS in x64 / arm64, Windows in x64, plus a runtime-less universal package — and the same file serves online and offline installation. Each package seals the program payload, its SHA256 checksum and the platform's installer: download the one file on a networked machine, copy it to the target, extract once and run the bundled installer — no network, no separate checksum file to carry (the sealed SHA256 is always verified).
|
||||
|
||||
**Linux (on arm64, use `penguin-linux-arm64-offline.tar.gz`):**
|
||||
**Linux (on arm64, use `penguin-linux-arm64.tar.gz`):**
|
||||
|
||||
```bash
|
||||
mkdir penguin-offline
|
||||
tar -xzf penguin-linux-x64-offline.tar.gz -C penguin-offline
|
||||
./penguin-offline/install.sh
|
||||
mkdir penguin-install
|
||||
tar -xzf penguin-linux-x64.tar.gz -C penguin-install
|
||||
./penguin-install/install.sh
|
||||
```
|
||||
|
||||
**macOS (Apple silicon shown; on Intel, use `penguin-darwin-x64-offline.tar.gz`):**
|
||||
**macOS (Apple silicon shown; on Intel, use `penguin-darwin-x64.tar.gz`):**
|
||||
|
||||
```bash
|
||||
mkdir penguin-offline
|
||||
tar -xzf penguin-darwin-arm64-offline.tar.gz -C penguin-offline
|
||||
./penguin-offline/install.sh
|
||||
mkdir penguin-install
|
||||
tar -xzf penguin-darwin-arm64.tar.gz -C penguin-install
|
||||
./penguin-install/install.sh
|
||||
```
|
||||
|
||||
**Windows (unzip, then double-click `install.cmd` — or run it in PowerShell):**
|
||||
|
||||
```powershell
|
||||
Expand-Archive penguin-win32-x64-offline.zip -DestinationPath penguin-offline
|
||||
cd penguin-offline
|
||||
Expand-Archive penguin-win32-x64.zip -DestinationPath penguin-install
|
||||
cd penguin-install
|
||||
.\install.cmd
|
||||
```
|
||||
|
||||
|
||||
+12
-12
@@ -139,31 +139,31 @@ penguin web # 启动服务并打开 http://127.0.0.1:7364
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>📴 离线安装包(无网环境)</b></summary>
|
||||
<summary><b>📴 离线安装(无网环境)</b></summary>
|
||||
|
||||
每个 <a href="https://github.com/Prism-Shadow/penguin-harness/releases">GitHub Release</a> 附带五个自包含的离线安装包——Linux 与 macOS 各有 x64 / arm64 两种架构,Windows 为 x64。包内自带程序压缩包、SHA256 校验文件与对应平台的安装器,在有网机器下载后拷贝到目标机器即可安装,全程无需联网(离线安装强制校验 SHA256)。
|
||||
每个 <a href="https://github.com/Prism-Shadow/penguin-harness/releases">GitHub Release</a> 每个目标只附带一个安装包——Linux 与 macOS 各有 x64 / arm64 两种架构,Windows 为 x64,另有不带运行时的 universal 包——同一个文件同时服务在线与离线安装。包内封入程序负载、其 SHA256 校验文件与对应平台的安装器:在有网机器下载这一个文件,拷贝到目标机器,解压一次并运行包内安装器即可——全程无需联网,也不必另外携带校验文件(包内封入的 SHA256 始终强制校验)。
|
||||
|
||||
**Linux(arm64 机器换用 `penguin-linux-arm64-offline.tar.gz`):**
|
||||
**Linux(arm64 机器换用 `penguin-linux-arm64.tar.gz`):**
|
||||
|
||||
```bash
|
||||
mkdir penguin-offline
|
||||
tar -xzf penguin-linux-x64-offline.tar.gz -C penguin-offline
|
||||
./penguin-offline/install.sh
|
||||
mkdir penguin-install
|
||||
tar -xzf penguin-linux-x64.tar.gz -C penguin-install
|
||||
./penguin-install/install.sh
|
||||
```
|
||||
|
||||
**macOS(Apple 芯片用 arm64 包,Intel 芯片换用 `penguin-darwin-x64-offline.tar.gz`):**
|
||||
**macOS(Apple 芯片用 arm64 包,Intel 芯片换用 `penguin-darwin-x64.tar.gz`):**
|
||||
|
||||
```bash
|
||||
mkdir penguin-offline
|
||||
tar -xzf penguin-darwin-arm64-offline.tar.gz -C penguin-offline
|
||||
./penguin-offline/install.sh
|
||||
mkdir penguin-install
|
||||
tar -xzf penguin-darwin-arm64.tar.gz -C penguin-install
|
||||
./penguin-install/install.sh
|
||||
```
|
||||
|
||||
**Windows(解压后双击 `install.cmd`,或在 PowerShell 中运行):**
|
||||
|
||||
```powershell
|
||||
Expand-Archive penguin-win32-x64-offline.zip -DestinationPath penguin-offline
|
||||
cd penguin-offline
|
||||
Expand-Archive penguin-win32-x64.zip -DestinationPath penguin-install
|
||||
cd penguin-install
|
||||
.\install.cmd
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" -ArchivePath "%~dp0penguin-win32-x64.zip"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" -ArchivePath "%~dp0payload.zip"
|
||||
set "INSTALL_EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
|
||||
+101
-41
@@ -7,6 +7,15 @@
|
||||
# $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)
|
||||
#
|
||||
# Each Release attaches exactly one Windows artifact: penguin-win32-x64.zip, a shallow installer
|
||||
# bundle holding install.cmd, this script, the program payload (payload.zip) and the payload's
|
||||
# checksum. Online installs download that bundle and verify it against its published .sha256;
|
||||
# offline installs transfer the same single file, extract it once (the outer layer is flat, so
|
||||
# no deep paths are created) and double-click install.cmd. Both paths verify the payload
|
||||
# checksum sealed inside the bundle, then expand the payload straight into the short staging
|
||||
# directory. Releases up to v0.1.5 shipped the program tree directly (top-level penguin\);
|
||||
# such zips are still accepted, from -Version pins and -ArchivePath files alike.
|
||||
#
|
||||
# There is no -Universal on Windows: where the zip is unsuitable, install Node.js >= 24 and run
|
||||
# `npm install -g @prismshadow/penguin-cli` instead.
|
||||
#
|
||||
@@ -25,6 +34,7 @@ $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering
|
||||
|
||||
$Repo = "https://github.com/Prism-Shadow/penguin-harness"
|
||||
$Asset = "penguin-win32-x64.zip"
|
||||
$PayloadName = "payload.zip"
|
||||
|
||||
function Fail([string]$Message) {
|
||||
# `throw` rather than `exit`: the penguin.ooo forwarder runs this installer as an in-memory
|
||||
@@ -33,6 +43,30 @@ function Fail([string]$Message) {
|
||||
throw "error: $Message"
|
||||
}
|
||||
|
||||
# Lists a zip's top-level entry names without extracting (PS 5.1-safe; Expand-Archive itself
|
||||
# uses the same .NET type). Used to tell an installer bundle (contains payload.zip) from a
|
||||
# program archive (payload.zip itself, or a pre-0.1.6 release zip with a top-level penguin\).
|
||||
function Get-ZipEntryNames([string]$ZipPath) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$Zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
try {
|
||||
return @($Zip.Entries | ForEach-Object { $_.FullName })
|
||||
} finally {
|
||||
$Zip.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Verifies a file against a sha256sum-format checksum file (`<hex> <filename>`; the first
|
||||
# token is the hash). Checksums are never optional: every install path either downloads the
|
||||
# published .sha256 or reads the one sealed inside the bundle.
|
||||
function Assert-Sha256([string]$FilePath, [string]$ShaPath, [string]$Label) {
|
||||
$Expected = ((Get-Content -LiteralPath $ShaPath -Raw).Trim() -split "\s+")[0]
|
||||
if (-not $Expected) { Fail "checksum file is empty or malformed: $ShaPath" }
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $FilePath).Hash
|
||||
if ($Actual -ine $Expected) { Fail "checksum mismatch for $([IO.Path]::GetFileName($FilePath))." }
|
||||
Write-Host "$Label checksum OK."
|
||||
}
|
||||
|
||||
function Restore-PreviousInstall(
|
||||
[string]$InstallDir,
|
||||
[string]$OldDir,
|
||||
@@ -63,11 +97,11 @@ if (-not $InstallDir) {
|
||||
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.
|
||||
# An extracted installer bundle keeps install.cmd, this script, payload.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
|
||||
$SiblingArchive = Join-Path $PSScriptRoot $PayloadName
|
||||
if (Test-Path -LiteralPath $SiblingArchive -PathType Leaf) { $ArchivePath = $SiblingArchive }
|
||||
}
|
||||
if ($ArchivePath -and $Version) {
|
||||
@@ -114,6 +148,7 @@ try {
|
||||
$ArchiveName = [IO.Path]::GetFileName($ZipPath)
|
||||
Write-Host "Using local archive $ZipPath ..."
|
||||
} else {
|
||||
# Online: download the canonical bundle; the published checksum is mandatory.
|
||||
Write-Host "Downloading $BaseUrl/$Asset ..."
|
||||
$ZipPath = Join-Path $Tmp $Asset
|
||||
try {
|
||||
@@ -122,11 +157,42 @@ try {
|
||||
Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
|
||||
}
|
||||
$ArchiveName = $Asset
|
||||
$ShaPath = Join-Path $Tmp "$Asset.sha256"
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
|
||||
} catch {
|
||||
Fail "checksum download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
|
||||
}
|
||||
Assert-Sha256 $ZipPath $ShaPath "Bundle"
|
||||
}
|
||||
|
||||
# --- SHA256 verify: mandatory offline; online keeps the existing warn-and-skip fallback. ---
|
||||
$HaveSha = $true
|
||||
if ($UsingLocalArchive) {
|
||||
# --- Resolve the program payload. An installer bundle (contains payload.zip) is opened flat
|
||||
# and its sealed payload checksum verified; a program archive (payload.zip itself, or a
|
||||
# pre-0.1.6 release zip with a top-level penguin\) is used directly. ---
|
||||
$ArchiveShape = if ((Get-ZipEntryNames $ZipPath) -contains $PayloadName) { "bundle" } else { "program" }
|
||||
if ($ArchiveShape -eq "bundle") {
|
||||
# A local bundle is self-verifying through its sealed payload checksum; when the published
|
||||
# outer .sha256 was transferred alongside it, verify that layer too.
|
||||
if ($UsingLocalArchive -and (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
|
||||
Assert-Sha256 $ZipPath "$ZipPath.sha256" "Bundle"
|
||||
}
|
||||
$BundleDir = Join-Path $Tmp "bundle"
|
||||
New-Item -ItemType Directory -Path $BundleDir | Out-Null
|
||||
Write-Host "Opening installer bundle ..."
|
||||
# The outer layer is flat (four files), so this extraction never creates deep paths.
|
||||
Expand-Archive -LiteralPath $ZipPath -DestinationPath $BundleDir -Force
|
||||
$ZipPath = Join-Path $BundleDir $PayloadName
|
||||
if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) {
|
||||
Fail "unexpected bundle layout: $PayloadName missing."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath "$ZipPath.sha256" -PathType Leaf)) {
|
||||
Fail "unexpected bundle layout: $PayloadName.sha256 missing."
|
||||
}
|
||||
Assert-Sha256 $ZipPath "$ZipPath.sha256" "Payload"
|
||||
} elseif ($UsingLocalArchive) {
|
||||
# Program archive from disk: an adjacent checksum is required — its own, or the canonical
|
||||
# asset checksum next to a renamed legacy file. (An online download was already verified
|
||||
# against the published .sha256 above.)
|
||||
$ShaPath = "$ZipPath.sha256"
|
||||
if (-not (Test-Path -LiteralPath $ShaPath -PathType Leaf) -and
|
||||
$ArchiveName -ine $Asset) {
|
||||
@@ -138,24 +204,7 @@ try {
|
||||
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 -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 {
|
||||
Fail "checksum mismatch for $Asset."
|
||||
}
|
||||
Assert-Sha256 $ZipPath $ShaPath "Payload"
|
||||
}
|
||||
|
||||
# --- Extract into staging, then swap by same-volume renames. Keep the previous dirs in .old.$PID
|
||||
@@ -187,7 +236,8 @@ try {
|
||||
if ([string]$TargetProperty.Value -ine "win32-x64") {
|
||||
Fail "package target mismatch: expected win32-x64, found $($TargetProperty.Value)."
|
||||
}
|
||||
} elseif ($UsingLocalArchive -and $ArchiveName -ine $Asset) {
|
||||
} elseif ($UsingLocalArchive -and $ArchiveShape -eq "program" -and
|
||||
$ArchiveName -ine $Asset -and $ArchiveName -ine $PayloadName) {
|
||||
Fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
|
||||
}
|
||||
|
||||
@@ -211,7 +261,13 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Launcher shims: shipped in the zip; (re)generate only when missing. ---
|
||||
# --- Launcher shim: shipped in the payload; (re)generated only when missing. There is
|
||||
# deliberately no penguin.ps1 launcher — PowerShell would prefer it over penguin.cmd on
|
||||
# PATH, and client Windows defaults to the Restricted execution policy, so a .ps1
|
||||
# launcher makes the plain `penguin` command fail with "running scripts is disabled".
|
||||
# Batch files are policy-exempt and both PowerShell and cmd.exe resolve penguin.cmd.
|
||||
# (bin\ is swapped wholesale above, so upgrading also removes the penguin.ps1 that
|
||||
# pre-0.1.6 payloads shipped.) ---
|
||||
$CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
|
||||
if (-not (Test-Path -LiteralPath $CmdShim)) {
|
||||
@(
|
||||
@@ -228,19 +284,6 @@ try {
|
||||
'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
|
||||
@@ -310,7 +353,24 @@ 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)
|
||||
$PathUpdateMessage = "note: installation succeeded and $BinDir was appended to your user Path. Restart your terminal so 'penguin' is found."
|
||||
# A raw registry write does not broadcast WM_SETTINGCHANGE, so Explorer — and every
|
||||
# terminal window launched from it afterwards — would keep the stale Path until the next
|
||||
# logon. Broadcast it the way [Environment]::SetEnvironmentVariable would; best-effort,
|
||||
# a failure only means new terminals need a fresh logon to see the Path.
|
||||
try {
|
||||
if (-not ("PenguinInstaller.NativeMethods" -as [type])) {
|
||||
Add-Type -Namespace PenguinInstaller -Name NativeMethods -MemberDefinition @'
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
|
||||
'@
|
||||
}
|
||||
$BroadcastResult = [UIntPtr]::Zero
|
||||
# HWND_BROADCAST (0xffff), WM_SETTINGCHANGE (0x1a), SMTO_ABORTIFHUNG (0x2), 5s timeout.
|
||||
[PenguinInstaller.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero,
|
||||
"Environment", 2, 5000, [ref]$BroadcastResult) | Out-Null
|
||||
} catch {
|
||||
}
|
||||
$PathUpdateMessage = "note: installation succeeded and $BinDir was appended to your user Path. Open a new terminal window so 'penguin' is found (a new tab of an already-running terminal keeps the old Path)."
|
||||
}
|
||||
} finally {
|
||||
$EnvKey.Close()
|
||||
|
||||
+139
-40
@@ -9,6 +9,15 @@
|
||||
# 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)
|
||||
#
|
||||
# Each Release attaches exactly one artifact per target: penguin-<target>.tar.gz, a shallow
|
||||
# installer bundle holding this script, the program payload (payload.tar.gz) and the payload's
|
||||
# checksum. Online installs download that bundle and verify it against its published .sha256;
|
||||
# offline installs transfer the same single file, extract it once and run the bundled
|
||||
# ./install.sh, which installs the sibling payload with no network access. Both paths verify
|
||||
# the payload checksum sealed inside the bundle before anything is staged. Releases up to
|
||||
# v0.1.5 shipped the program tree directly (top-level penguin/); such archives are still
|
||||
# accepted, from --version pins and --archive files alike.
|
||||
#
|
||||
# The data dir (~/.penguin/data) sits under the install home but is never touched by reinstall/upgrade (which only replace bin/lib/web/node).
|
||||
#
|
||||
# Docs: https://penguin.ooo/docs/installation
|
||||
@@ -20,6 +29,7 @@ INSTALL_DIR="${PENGUIN_INSTALL_DIR:-$HOME/.penguin}"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
UNIVERSAL=0
|
||||
ARCHIVE="${PENGUIN_ARCHIVE:-}"
|
||||
PAYLOAD_NAME="payload.tar.gz"
|
||||
|
||||
fail() {
|
||||
echo "error: $1" >&2
|
||||
@@ -51,7 +61,6 @@ 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
|
||||
Linux) os="linux" ;;
|
||||
@@ -64,8 +73,8 @@ if [ "$UNIVERSAL" -eq 0 ]; then
|
||||
*) fail "unsupported architecture: $(uname -m). Install Node.js >= 24, then re-run with --universal." ;;
|
||||
esac
|
||||
TARGET="$os-$arch"
|
||||
ASSET="penguin-$TARGET.tar.gz"
|
||||
fi
|
||||
ASSET="penguin-$TARGET.tar.gz"
|
||||
|
||||
if [ -n "$ARCHIVE" ] && [ -n "$VERSION" ]; then
|
||||
fail "--archive/PENGUIN_ARCHIVE cannot be combined with --version/PENGUIN_VERSION"
|
||||
@@ -90,14 +99,47 @@ SWAP_ACTIVE=0
|
||||
MOVED_OLD=""
|
||||
MOVED_NEW=""
|
||||
|
||||
# Moves one directory to a new location even when the directory inode itself cannot be renamed
|
||||
# — a mount point, a process's CWD, or a filesystem that pins in-use directories (overlayfs
|
||||
# under Docker reports EBUSY when `penguin update` replaces the very lib/ its own process runs
|
||||
# from). Strategies, in order: plain rename; per-entry renames into a fresh or existing
|
||||
# destination; per-entry copy with the source entry removed (POSIX keeps an unlinked-but-open
|
||||
# file valid for the process using it). A pinned, now-empty source directory is left in place
|
||||
# and reused by the incoming move.
|
||||
relocate_dir() {
|
||||
rl_src="$1"
|
||||
rl_dst="$2"
|
||||
if [ ! -e "$rl_dst" ] && mv "$rl_src" "$rl_dst" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
[ -d "$rl_src" ] || return 1
|
||||
mkdir -p "$rl_dst" || return 1
|
||||
rl_failed=0
|
||||
for rl_entry in "$rl_src"/* "$rl_src"/.[!.]* "$rl_src"/..?*; do
|
||||
[ -e "$rl_entry" ] || [ -L "$rl_entry" ] || continue
|
||||
if ! mv "$rl_entry" "$rl_dst/" 2>/dev/null; then
|
||||
cp -Rp "$rl_entry" "$rl_dst/" || rl_failed=1
|
||||
rm -rf "$rl_entry" || rl_failed=1
|
||||
fi
|
||||
done
|
||||
[ "$rl_failed" -eq 0 ] || return 1
|
||||
[ -z "$(ls -A "$rl_src" 2>/dev/null)" ] || return 1
|
||||
rmdir "$rl_src" 2>/dev/null || :
|
||||
return 0
|
||||
}
|
||||
|
||||
rollback_install() {
|
||||
rollback_failed=0
|
||||
for d in $MOVED_NEW; do
|
||||
rm -rf "$INSTALL_DIR/$d" || rollback_failed=1
|
||||
# Clearing may leave a pinned-but-empty directory husk; the restore below reuses it.
|
||||
rm -rf "$INSTALL_DIR/$d" 2>/dev/null || :
|
||||
if [ -e "$INSTALL_DIR/$d" ] && [ -n "$(ls -A "$INSTALL_DIR/$d" 2>/dev/null)" ]; then
|
||||
rollback_failed=1
|
||||
fi
|
||||
done
|
||||
for d in $MOVED_OLD; do
|
||||
if [ -e "$OLD_DIR/$d" ]; then
|
||||
mv "$OLD_DIR/$d" "$INSTALL_DIR/$d" || rollback_failed=1
|
||||
relocate_dir "$OLD_DIR/$d" "$INSTALL_DIR/$d" || rollback_failed=1
|
||||
fi
|
||||
done
|
||||
if [ "$rollback_failed" -ne 0 ]; then
|
||||
@@ -125,67 +167,124 @@ trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# --- Resolve local/offline archive or download from GitHub. ---
|
||||
# Verifies file $1 against the sha256sum-format file $2 ($3 names the layer in messages).
|
||||
# Checksums are never optional: every install path either downloads the published .sha256 or
|
||||
# reads the one sealed inside the bundle.
|
||||
verify_sha256() {
|
||||
vs_expected="$(awk 'NR == 1 { print $1 }' "$2" | tr 'A-F' 'a-f')"
|
||||
[ -n "$vs_expected" ] || fail "checksum file is empty or malformed: $2"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
vs_actual="$(sha256sum "$1" | awk '{ print $1 }')"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
vs_actual="$(shasum -a 256 "$1" | awk '{ print $1 }')"
|
||||
else
|
||||
fail "sha256sum or shasum is required for checksum verification"
|
||||
fi
|
||||
[ "$vs_actual" = "$vs_expected" ] || fail "checksum mismatch for $3."
|
||||
echo "$3 checksum OK."
|
||||
}
|
||||
|
||||
# --- Resolve the program payload. Three entries converge on PAYLOAD_PATH:
|
||||
# (a) bundled offline: this script sits next to payload.tar.gz in an extracted bundle;
|
||||
# (b) --archive <file>: a local installer bundle, or a payload/legacy program archive;
|
||||
# (c) online: download penguin-<target>.tar.gz and verify it, then open it.
|
||||
# A bundle is recognized by containing payload.tar.gz at its top level; anything else is a
|
||||
# program archive (payload.tar.gz itself, or a pre-0.1.6 release archive) whose top level
|
||||
# is penguin/. ---
|
||||
LOCAL_ARCHIVE=0
|
||||
HAVE_SHA=0
|
||||
if [ -n "$ARCHIVE" ]; then
|
||||
ARCHIVE_SHAPE=""
|
||||
ARCHIVE_PATH=""
|
||||
ARCHIVE_NAME=""
|
||||
PAYLOAD_PATH=""
|
||||
|
||||
# Sibling pickup engages only for a real file actually named install.sh — the name it carries
|
||||
# inside a bundle. A forwarder or `curl | sh` run never satisfies that (no file, or a random
|
||||
# temp name), so an online installer cannot be steered by archives someone planted next to a
|
||||
# temporary script in a shared directory.
|
||||
SIBLING_PAYLOAD=""
|
||||
if [ -z "$ARCHIVE" ] && [ -f "$0" ] && [ "$(basename "$0")" = "install.sh" ]; then
|
||||
script_dir="$(CDPATH= cd "$(dirname "$0")" && pwd)"
|
||||
if [ -f "$script_dir/$PAYLOAD_NAME" ]; then
|
||||
SIBLING_PAYLOAD="$script_dir/$PAYLOAD_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$SIBLING_PAYLOAD" ]; then
|
||||
# (a) Extracted bundle: install the sibling payload; no network access at all.
|
||||
[ -z "$VERSION" ] \
|
||||
|| fail "--version/PENGUIN_VERSION cannot be combined with the bundled offline installer"
|
||||
echo "Using bundled payload $SIBLING_PAYLOAD ..."
|
||||
[ -f "$SIBLING_PAYLOAD.sha256" ] \
|
||||
|| fail "offline checksum file not found: $SIBLING_PAYLOAD.sha256"
|
||||
verify_sha256 "$SIBLING_PAYLOAD" "$SIBLING_PAYLOAD.sha256" "Payload"
|
||||
PAYLOAD_PATH="$SIBLING_PAYLOAD"
|
||||
ARCHIVE_NAME="$PAYLOAD_NAME"
|
||||
LOCAL_ARCHIVE=1
|
||||
elif [ -n "$ARCHIVE" ]; then
|
||||
# (b) Explicit local archive; its shape is probed below.
|
||||
[ -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
|
||||
# (c) Online: download the canonical bundle; the published checksum is mandatory.
|
||||
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
|
||||
curl -fsSL "$BASE_URL/$ASSET.sha256" -o "$TMP/$ASSET.sha256" \
|
||||
|| fail "checksum download failed. Check the version tag and your network, then retry."
|
||||
verify_sha256 "$ARCHIVE_PATH" "$TMP/$ASSET.sha256" "Bundle"
|
||||
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 }')"
|
||||
if [ -z "$PAYLOAD_PATH" ]; then
|
||||
if tar -tzf "$ARCHIVE_PATH" 2>/dev/null | head -50 | grep -qE "^(\./)?$PAYLOAD_NAME\$"; then
|
||||
ARCHIVE_SHAPE="bundle"
|
||||
else
|
||||
if [ "$LOCAL_ARCHIVE" -eq 1 ]; then
|
||||
fail "offline installation requires sha256sum or shasum for checksum verification"
|
||||
ARCHIVE_SHAPE="program"
|
||||
fi
|
||||
if [ "$ARCHIVE_SHAPE" = "bundle" ]; then
|
||||
# A local bundle is self-verifying through its sealed payload checksum; when the published
|
||||
# outer .sha256 was transferred alongside it, verify that layer too.
|
||||
if [ "$LOCAL_ARCHIVE" -eq 1 ] && [ -f "$ARCHIVE_PATH.sha256" ]; then
|
||||
verify_sha256 "$ARCHIVE_PATH" "$ARCHIVE_PATH.sha256" "Bundle"
|
||||
fi
|
||||
actual=""
|
||||
echo "warning: sha256sum/shasum not found; skipping checksum verification." >&2
|
||||
BUNDLE_DIR="$TMP/bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
tar -xzf "$ARCHIVE_PATH" -C "$BUNDLE_DIR"
|
||||
PAYLOAD_PATH="$BUNDLE_DIR/$PAYLOAD_NAME"
|
||||
[ -f "$PAYLOAD_PATH" ] || fail "unexpected bundle layout: $PAYLOAD_NAME missing."
|
||||
[ -f "$PAYLOAD_PATH.sha256" ] || fail "unexpected bundle layout: $PAYLOAD_NAME.sha256 missing."
|
||||
verify_sha256 "$PAYLOAD_PATH" "$PAYLOAD_PATH.sha256" "Payload"
|
||||
else
|
||||
# Program archive (payload.tar.gz, or a pre-0.1.6 release archive). A local file needs an
|
||||
# adjacent checksum — its own, or the canonical asset checksum next to a renamed legacy
|
||||
# file; an online download was already verified against the published .sha256 above.
|
||||
if [ "$LOCAL_ARCHIVE" -eq 1 ]; then
|
||||
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"
|
||||
verify_sha256 "$ARCHIVE_PATH" "$SHA_PATH" "Payload"
|
||||
fi
|
||||
PAYLOAD_PATH="$ARCHIVE_PATH"
|
||||
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 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"
|
||||
tar -xzf "$PAYLOAD_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
|
||||
@@ -193,7 +292,7 @@ if [ -f "$MANIFEST_PATH" ]; then
|
||||
[ -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
|
||||
elif [ "$LOCAL_ARCHIVE" -eq 1 ] && [ "$ARCHIVE_SHAPE" = "program" ] && [ "$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"
|
||||
@@ -222,16 +321,16 @@ mkdir -p "$OLD_DIR"
|
||||
SWAP_ACTIVE=1
|
||||
for d in bin lib web node; do
|
||||
if [ -e "$INSTALL_DIR/$d" ]; then
|
||||
if mv "$INSTALL_DIR/$d" "$OLD_DIR/$d"; then
|
||||
if relocate_dir "$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."
|
||||
fail "could not move the existing $d directory aside (stop running penguin processes and retry); 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
|
||||
if relocate_dir "$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."
|
||||
|
||||
@@ -17,7 +17,7 @@ On Linux / macOS:
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
```
|
||||
|
||||
The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz`, which bundles an official Node.js runtime. Other POSIX platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz` (Windows is served by its own installer below, not by `--universal`).
|
||||
The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz` — the canonical installer bundle, sealing the program payload (with an official Node.js runtime), the payload's SHA256 checksum and this same installer. The download is verified against its published `.sha256`, then the sealed payload checksum is verified again before anything is staged. Other POSIX platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz` bundle (Windows is served by its own installer below, not by `--universal`).
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
@@ -37,14 +37,14 @@ Verify the install:
|
||||
penguin -v
|
||||
```
|
||||
|
||||
### Offline bundles
|
||||
### Offline install
|
||||
|
||||
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.
|
||||
The same Release artifacts serve offline installation — there is no separate offline package. Download the file matching the target computer on a connected machine (`penguin-<target>.tar.gz`, or `penguin-win32-x64.zip` for Windows), transfer that one file, then extract it once.
|
||||
|
||||
On Windows, double-click `install.cmd`, or run:
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -ArchivePath .\penguin-win32-x64.zip
|
||||
.\install.ps1
|
||||
```
|
||||
|
||||
On Linux / macOS, run:
|
||||
@@ -53,7 +53,7 @@ On Linux / macOS, run:
|
||||
./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`.
|
||||
The extracted bundle keeps the installer, the program payload (`payload.tar.gz` / `payload.zip`) and the payload's `.sha256` together; the installer finds the sibling payload by itself, always verifies the sealed checksum and performs no network requests — no separate checksum file needs to be transferred. You can also point the installer at a file explicitly: `install.sh --archive <file>`, `PENGUIN_ARCHIVE=<file>`, `install.ps1 -ArchivePath <file>`, or `$env:PENGUIN_ARCHIVE` — accepting a Release bundle, its inner payload, or a pre-0.1.6 legacy program archive alike.
|
||||
|
||||
### Install location and options
|
||||
|
||||
@@ -62,8 +62,8 @@ The extracted bundle keeps the matching program archive, its `.sha256` file and
|
||||
| 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 |
|
||||
| Local archive | `PENGUIN_ARCHIVE=<file>` or `--archive <file>`; accepts a Release bundle (self-verifying via its sealed payload checksum) or a payload/legacy program archive with an adjacent `<file>.sha256` (renamed legacy files may use the platform asset's canonical `.sha256`) |
|
||||
| Integrity check | Always on: online downloads are verified against the published `.sha256`, and bundle payloads against the checksum sealed inside the bundle |
|
||||
| Upgrade | Re-run the install script; files are swapped atomically |
|
||||
|
||||
Script flags go after `sh -s --`, e.g. `curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal`.
|
||||
@@ -73,17 +73,17 @@ Script flags go after `sh -s --`, e.g. `curl -fsSL https://penguin.ooo/install.s
|
||||
| Item | Details |
|
||||
| --- | --- |
|
||||
| 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) |
|
||||
| Command entry | the `bin\penguin.cmd` launcher (deliberately no `.ps1` launcher — batch files are exempt from the PowerShell execution policy, so `penguin` works even under the default Restricted policy); the installer adds `%USERPROFILE%\.penguin\bin` to your **user** Path and broadcasts the change — open a **new terminal window** once (a new tab of an already-running terminal keeps the old Path) |
|
||||
| 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 |
|
||||
| Local archive | `$env:PENGUIN_ARCHIVE = "<file>"` or `-ArchivePath <file>`; accepts the Release bundle (self-verifying via its sealed payload checksum) or a payload/legacy zip with an adjacent `<file>.sha256` (renamed legacy files may use `penguin-win32-x64.zip.sha256`) |
|
||||
| Integrity check | Always on: online downloads are verified against the published `.sha256`, and bundle payloads against the checksum sealed inside the bundle |
|
||||
| Upgrade | Re-run the installer; it swaps `bin`/`lib`/`web`/`node` and never touches `data` |
|
||||
|
||||
- **Agent shell**: on Windows, the agent's `exec_command` runs in a POSIX shell, for compatibility with skills written for one. It picks, in order: `bash` on PATH (your own [Git for Windows](https://gitforwindows.org/), preferred because it carries the full MSYS userland); then the **bundled bash** — the Windows zip ships MinGit under `git\`, so a machine with no Git for Windows still gets a POSIX shell, about sixty core utilities and `git.exe`; then PowerShell (`pwsh`, then `powershell`). The PowerShell fallback is only reached by npm installs, which bundle nothing. The `PENGUIN_SHELL` env var overrides the pick; the session's system prompt tells the model which shell is active. The bundled shell's licensing is recorded in [THIRD-PARTY-NOTICES.md](https://github.com/Prism-Shadow/penguin-harness/blob/main/THIRD-PARTY-NOTICES.md).
|
||||
- **Ctrl-C semantics**: on Windows, sending Ctrl-C to a running command session (`input_command` with `"\u0003"`) terminates the whole command session tree instead of interrupting the foreground command — Windows cannot deliver a console Ctrl-C to a piped child process, so the interrupt degrades to a hard tree kill.
|
||||
- **In-place update**: `penguin update` is not yet supported on Windows — upgrade by re-running the installer above.
|
||||
- **Config file permissions**: on POSIX, config/credential files are written with `0600` (owner-only) permissions; Windows has no such mode bits, so files fall under your profile's default NTFS ACLs.
|
||||
- If PowerShell refuses to run `penguin` with "running scripts is disabled", your execution policy blocks the `penguin.ps1` shim: either call `penguin.cmd` explicitly, or allow local scripts with `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
||||
- If PowerShell refuses to run `penguin` with "running scripts is disabled", the blocked file is a `penguin.ps1` launcher — from an install older than 0.1.6 (re-run the installer: upgrades replace `bin\` and remove it) or generated by an npm global install (call `penguin.cmd` explicitly, or allow local scripts with `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`). The packaged install itself ships only `penguin.cmd`, which runs under any execution policy.
|
||||
|
||||
### Data directory
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ description: 通过安装脚本、npm 或源码安装 PenguinHarness。
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
```
|
||||
|
||||
脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`,其中捆绑了官方 Node.js 运行时。其他 POSIX 平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz`(Windows 使用下方专属安装器,而不是 `--universal`)。
|
||||
脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`——即标准安装包:包内封入程序负载(捆绑官方 Node.js 运行时)、负载的 SHA256 校验文件与同一个安装器。下载后先对照 Release 发布的 `.sha256` 校验外层,再校验包内封入的负载 checksum,然后才进入暂存安装。其他 POSIX 平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz` 安装包(Windows 使用下方专属安装器,而不是 `--universal`)。
|
||||
|
||||
在 Windows(PowerShell)上执行:
|
||||
|
||||
@@ -37,14 +37,14 @@ $env:PENGUIN_VERSION = "vX.Y.Z"; irm https://penguin.ooo/install.ps1 | iex
|
||||
penguin -v
|
||||
```
|
||||
|
||||
### 离线安装包
|
||||
### 离线安装
|
||||
|
||||
每个 Release 还会分别提供 Windows x64、Linux x64/arm64 与 macOS x64/arm64 的完整离线包。先在可联网电脑上下载与目标电脑匹配的离线包,传输到目标电脑后解压一次。
|
||||
离线安装使用与在线安装相同的 Release 制品——不再有单独的离线包。先在可联网电脑上下载与目标电脑匹配的那一个文件(`penguin-<target>.tar.gz`,Windows 为 `penguin-win32-x64.zip`),传输后解压一次。
|
||||
|
||||
Windows 上双击 `install.cmd`,或执行:
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -ArchivePath .\penguin-win32-x64.zip
|
||||
.\install.ps1
|
||||
```
|
||||
|
||||
Linux / macOS 上执行:
|
||||
@@ -53,7 +53,7 @@ Linux / macOS 上执行:
|
||||
./install.sh
|
||||
```
|
||||
|
||||
解压后的目录同时包含对应平台的程序压缩包、`.sha256` 文件和离线安装入口。离线包内的 `install.sh` 会将同包内的程序压缩包显式传给实际安装器,强制完成 checksum 校验,并且不会发起任何网络请求。也可以使用 Release 中单独发布的安装器显式指定本地文件:`install.sh --archive <file>`、`PENGUIN_ARCHIVE=<file>`、`install.ps1 -ArchivePath <file>` 或 `$env:PENGUIN_ARCHIVE`。
|
||||
解压后的目录同时包含安装器、程序负载(`payload.tar.gz` / `payload.zip`)与负载的 `.sha256`;安装器会自行找到同目录负载,始终校验包内封入的 checksum,且不发起任何网络请求——无需另外传输校验文件。也可以显式指定本地文件:`install.sh --archive <file>`、`PENGUIN_ARCHIVE=<file>`、`install.ps1 -ArchivePath <file>` 或 `$env:PENGUIN_ARCHIVE`——Release 安装包、其内部负载或 0.1.6 之前的旧版程序压缩包均可。
|
||||
|
||||
### 安装位置与选项
|
||||
|
||||
@@ -62,8 +62,8 @@ Linux / macOS 上执行:
|
||||
| 安装目录 | 默认 `~/.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 校验 |
|
||||
| 本地压缩包 | `PENGUIN_ARCHIVE=<file>` 或 `--archive <file>`;接受 Release 安装包(凭包内封入的负载 checksum 自校验),或旁边带 `<file>.sha256` 的负载 / 旧版程序压缩包(重命名的旧版文件可用平台标准名称的 `.sha256`) |
|
||||
| 完整性校验 | 始终进行:在线下载对照发布的 `.sha256` 校验,安装包负载对照包内封入的 checksum 校验 |
|
||||
| 升级 | 重新执行安装脚本即可,文件原子替换 |
|
||||
|
||||
脚本参数写在 `sh -s --` 之后,例如 `curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal`。
|
||||
@@ -73,17 +73,17 @@ Linux / macOS 上执行:
|
||||
| 项目 | 说明 |
|
||||
| --- | --- |
|
||||
| 安装目录 | 默认 `%USERPROFILE%\.penguin`,可用环境变量 `PENGUIN_INSTALL_DIR` 覆盖 |
|
||||
| 命令入口 | `bin\penguin.cmd` 与 `bin\penguin.ps1` 启动器;安装器会把 `%USERPROFILE%\.penguin\bin` 加入**用户** Path(重启终端后生效) |
|
||||
| 命令入口 | `bin\penguin.cmd` 启动器(特意不带 `.ps1` 启动器——批处理不受 PowerShell 执行策略限制,默认 Restricted 策略下 `penguin` 也能直接运行);安装器会把 `%USERPROFILE%\.penguin\bin` 加入**用户** Path 并广播变更——请**新开一个终端窗口**(已开终端的新标签页仍沿用旧 Path) |
|
||||
| 版本固定 | 运行安装器前设置 `$env:PENGUIN_VERSION = "vX.Y.Z"` |
|
||||
| 本地压缩包 | `$env:PENGUIN_ARCHIVE = "<file>"` 或 `-ArchivePath <file>`;允许重命名,要求旁边存在 `<file>.sha256` 或 `penguin-win32-x64.zip.sha256` |
|
||||
| 完整性校验 | Release 提供 checksum 资产时自动进行 sha256 校验 |
|
||||
| 本地压缩包 | `$env:PENGUIN_ARCHIVE = "<file>"` 或 `-ArchivePath <file>`;接受 Release 安装包(凭包内封入的负载 checksum 自校验),或旁边带 `<file>.sha256` 的负载 / 旧版 zip(重命名的旧版文件可用 `penguin-win32-x64.zip.sha256`) |
|
||||
| 完整性校验 | 始终进行:在线下载对照发布的 `.sha256` 校验,安装包负载对照包内封入的 checksum 校验 |
|
||||
| 升级 | 重新运行安装器;只替换 `bin`/`lib`/`web`/`node`,绝不触碰 `data` |
|
||||
|
||||
- **Agent shell**:Windows 上 `exec_command` 在 POSIX shell 中执行,以兼容面向 POSIX 编写的技能生态。选择顺序为:PATH 上的 `bash`(你自己安装的 [Git for Windows](https://gitforwindows.org/),优先,因为它带完整的 MSYS 工具集);其次是**内置 bash**——Windows zip 在 `git\` 下自带 MinGit,因此未安装 Git for Windows 的机器同样有 POSIX shell、约六十个核心工具和 `git.exe`;最后才是 PowerShell(先 `pwsh` 后 `powershell`)。只有经 npm 安装(不含内置包)才会走到 PowerShell。环境变量 `PENGUIN_SHELL` 可强制指定;会话的系统提示词会告知模型当前 shell。内置 shell 的许可信息见 [THIRD-PARTY-NOTICES.md](https://github.com/Prism-Shadow/penguin-harness/blob/main/THIRD-PARTY-NOTICES.md)。
|
||||
- **Ctrl-C 语义**:Windows 上向运行中的命令会话发送 Ctrl-C(`input_command` 传 `"\u0003"`)会终止整棵命令会话进程树,而不是中断前台命令——Windows 无法向管道子进程投递控制台 Ctrl-C,中断因此退化为整树强杀。
|
||||
- **就地更新**:`penguin update` 暂不支持 Windows——升级请重新运行上面的安装器。
|
||||
- **配置文件权限**:POSIX 上配置/凭据文件以 `0600`(仅属主可读写)写入;Windows 没有对应的权限位,文件遵循你用户目录的默认 NTFS ACL。
|
||||
- 如果 PowerShell 提示 "running scripts is disabled" 而无法运行 `penguin`,是执行策略拦住了 `penguin.ps1`:可以显式调用 `penguin.cmd`,或用 `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` 允许本地脚本。
|
||||
- 如果 PowerShell 提示 "running scripts is disabled" 而无法运行 `penguin`,被拦下的是某个 `penguin.ps1` 启动器——来自 0.1.6 之前的旧安装(重新运行安装器即可:升级会整体替换 `bin\` 并移除它),或来自 npm 全局安装生成的 shim(可显式调用 `penguin.cmd`,或用 `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` 允许本地脚本)。安装包本身只带 `penguin.cmd`,任何执行策略下都能运行。
|
||||
|
||||
### 数据目录
|
||||
|
||||
|
||||
@@ -12,10 +12,13 @@ set -eu
|
||||
# Download to a file first, then run it: piping straight into `sh` would execute
|
||||
# a truncated download line by line, and the real installer removes the old
|
||||
# bin/lib/web/node before moving the new ones in — a cut connection mid-way
|
||||
# would leave no install at all.
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
curl -fsSL "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh" -o "$TMP"
|
||||
# would leave no install at all. The file lives in a private mktemp -d (0700)
|
||||
# directory: the real installer picks up a payload sitting next to a script
|
||||
# named install.sh (the extracted-bundle offline path), and a shared /tmp must
|
||||
# never offer that seam to other local users.
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
curl -fsSL "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh" -o "$TMP_DIR/install.sh"
|
||||
rc=0
|
||||
sh "$TMP" "$@" || rc=$?
|
||||
sh "$TMP_DIR/install.sh" "$@" || rc=$?
|
||||
exit "$rc"
|
||||
|
||||
@@ -25,20 +25,21 @@ export const INSTALL_CMD = "curl -fsSL https://penguin.ooo/install.sh | sh";
|
||||
export const INSTALL_CMD_WINDOWS = "irm https://penguin.ooo/install.ps1 | iex";
|
||||
|
||||
/**
|
||||
* Offline-bundle install commands, per OS tab of the install switcher. Each Release
|
||||
* attaches five self-contained bundles (see scripts/package-offline-bundles.sh); the
|
||||
* commands show the most common architecture — the localized hint strings name the
|
||||
* alternative archive. Language-neutral, like the one-liners above.
|
||||
* Offline install commands, per OS tab of the install switcher. Each Release attaches one
|
||||
* installer bundle per target (see scripts/package-release-bundles.sh) and the same file
|
||||
* serves online and offline installation; the commands show the most common architecture —
|
||||
* the localized hint strings name the alternative archive. Language-neutral, like the
|
||||
* one-liners above.
|
||||
*/
|
||||
export const OFFLINE_INSTALL_CMDS: Record<"linux" | "macos" | "windows", string> = {
|
||||
linux: `mkdir penguin-offline
|
||||
tar -xzf penguin-linux-x64-offline.tar.gz -C penguin-offline
|
||||
./penguin-offline/install.sh`,
|
||||
macos: `mkdir penguin-offline
|
||||
tar -xzf penguin-darwin-arm64-offline.tar.gz -C penguin-offline
|
||||
./penguin-offline/install.sh`,
|
||||
windows: `Expand-Archive penguin-win32-x64-offline.zip -DestinationPath penguin-offline
|
||||
cd penguin-offline
|
||||
linux: `mkdir penguin-install
|
||||
tar -xzf penguin-linux-x64.tar.gz -C penguin-install
|
||||
./penguin-install/install.sh`,
|
||||
macos: `mkdir penguin-install
|
||||
tar -xzf penguin-darwin-arm64.tar.gz -C penguin-install
|
||||
./penguin-install/install.sh`,
|
||||
windows: `Expand-Archive penguin-win32-x64.zip -DestinationPath penguin-install
|
||||
cd penguin-install
|
||||
.\\install.cmd`,
|
||||
};
|
||||
|
||||
|
||||
@@ -71,11 +71,10 @@ export const en: Strings = {
|
||||
online: "Online install",
|
||||
offline: "Offline package",
|
||||
offlineNote:
|
||||
"Every GitHub Release attaches five self-contained offline bundles (Linux / macOS in x64 and arm64, Windows in x64), each carrying the program archive, its SHA256 checksum and the installer: download on a networked machine, copy to the target, and install with no network at all.",
|
||||
"Every GitHub Release attaches one package per target (Linux / macOS in x64 and arm64, Windows in x64) and the same file serves online and offline installation. Each package seals the program payload, its SHA256 checksum and the installer: download that one file on a networked machine, copy it to the target, extract once and install with no network at all.",
|
||||
offlineHints: {
|
||||
linux: "On arm64 machines, use penguin-linux-arm64-offline.tar.gz.",
|
||||
macos:
|
||||
"Apple silicon uses the arm64 bundle; on Intel, use penguin-darwin-x64-offline.tar.gz.",
|
||||
linux: "On arm64 machines, use penguin-linux-arm64.tar.gz.",
|
||||
macos: "Apple silicon uses the arm64 package; on Intel, use penguin-darwin-x64.tar.gz.",
|
||||
windows: "After unzipping you can also just double-click install.cmd.",
|
||||
},
|
||||
offlineRelease: "Download offline packages from GitHub Releases",
|
||||
|
||||
@@ -77,10 +77,10 @@ export const zh = {
|
||||
online: "在线安装",
|
||||
offline: "离线安装包",
|
||||
offlineNote:
|
||||
"每个 GitHub Release 附带五个自包含离线安装包(Linux / macOS 各 x64 与 arm64,Windows 为 x64),内含程序包、SHA256 校验文件与安装器:在有网机器下载,拷贝到目标机器安装,全程无需联网。",
|
||||
"每个 GitHub Release 每个目标只附带一个安装包(Linux / macOS 各 x64 与 arm64,Windows 为 x64),同一个文件同时服务在线与离线安装。包内封入程序负载、SHA256 校验文件与安装器:在有网机器下载这一个文件,拷贝到目标机器解压安装,全程无需联网。",
|
||||
offlineHints: {
|
||||
linux: "arm64 机器换用 penguin-linux-arm64-offline.tar.gz。",
|
||||
macos: "Apple 芯片用 arm64 包,Intel 芯片换用 penguin-darwin-x64-offline.tar.gz。",
|
||||
linux: "arm64 机器换用 penguin-linux-arm64.tar.gz。",
|
||||
macos: "Apple 芯片用 arm64 包,Intel 芯片换用 penguin-darwin-x64.tar.gz。",
|
||||
windows: "解压后也可直接双击 install.cmd 完成安装。",
|
||||
},
|
||||
offlineRelease: "前往 GitHub Releases 下载离线安装包",
|
||||
|
||||
@@ -63,7 +63,7 @@ import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
|
||||
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
|
||||
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
||||
import { UpdateDialog } from "../account/update-dialog";
|
||||
import { forceUpdateCheck, useVersionInfo } from "../../lib/use-version-info";
|
||||
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
|
||||
|
||||
function Icon({ d, size = 16 }: { d: string; size?: number }) {
|
||||
return (
|
||||
@@ -244,20 +244,20 @@ export function Sidebar({
|
||||
/**
|
||||
* Manual update check (owner request): forces a lookup past the server's TTL cache and
|
||||
* pushes the result into the shared version-info store, so the reminder rows, badge,
|
||||
* and dot appear immediately when a newer release is found — that visible change is
|
||||
* the notification then. A toast fires only when nothing changes visibly (#54, one
|
||||
* notification per action): up to date, checks disabled, or a failed lookup (the
|
||||
* check is fail-soft — failure arrives as the `error` field, not an exception; the
|
||||
* catch handles our own server being unreachable).
|
||||
* and dot appear immediately when a newer release is found. Every outcome also toasts —
|
||||
* up to date, found (naming the release; the row below turns into the update entry),
|
||||
* checks disabled, and a failed lookup (the check is fail-soft — failure arrives as the
|
||||
* `error` field, not an exception; the catch handles our own server being unreachable).
|
||||
*/
|
||||
const runUpdateCheck = async () => {
|
||||
if (updateChecking) return;
|
||||
setUpdateChecking(true);
|
||||
try {
|
||||
const res = await forceUpdateCheck();
|
||||
if (res.disabled === true) toastInfo(S.update.checkDisabled);
|
||||
else if (res.error !== undefined) toastError(S.update.checkFailed);
|
||||
else if (!res.updateAvailable) toastSuccess(S.update.upToDate);
|
||||
const outcome = updateCheckOutcome(await forceUpdateCheck());
|
||||
if (outcome.kind === "disabled") toastInfo(S.update.checkDisabled);
|
||||
else if (outcome.kind === "failed") toastError(S.update.checkFailed);
|
||||
else if (outcome.kind === "found") toastSuccess(S.update.foundNew(outcome.latestVersion));
|
||||
else toastSuccess(S.update.upToDate);
|
||||
} catch (e) {
|
||||
toastError(apiErrorText(e));
|
||||
} finally {
|
||||
@@ -1099,7 +1099,13 @@ export function Sidebar({
|
||||
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{newVersion !== null && (
|
||||
{updateChecking && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
|
||||
/>
|
||||
)}
|
||||
{!updateChecking && newVersion !== null && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
|
||||
|
||||
@@ -67,6 +67,8 @@ export const en: Strings = {
|
||||
*/
|
||||
checkNow: "Check for updates",
|
||||
checking: "Checking…",
|
||||
/** Success toast when the manual check finds a newer release; the row below turns into the update entry. */
|
||||
foundNew: (v: string) => `New version v${v} found — use the update entry below to install`,
|
||||
upToDate: "You're on the latest version",
|
||||
checkFailed: "Update check failed — try again later",
|
||||
checkDisabled: "Update checks are disabled (PENGUIN_UPDATE_CHECK=off)",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const zh = {
|
||||
*/
|
||||
checkNow: "检查更新",
|
||||
checking: "检查中…",
|
||||
/** 手动检查发现新版本时的成功提示;下方同一行即变为更新入口。 */
|
||||
foundNew: (v: string) => `发现新版本 v${v},点击下方更新入口即可安装`,
|
||||
upToDate: "已是最新版本",
|
||||
checkFailed: "检查更新失败,请稍后重试",
|
||||
checkDisabled: "更新检查已关闭(PENGUIN_UPDATE_CHECK=off)",
|
||||
|
||||
@@ -28,6 +28,28 @@ let updatePromise: Promise<UpdateCheckResponse> | null = null;
|
||||
*/
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** How one manual update check ended, for user feedback — exactly one notice per outcome. */
|
||||
export type UpdateCheckOutcome =
|
||||
| { kind: "disabled" }
|
||||
| { kind: "failed" }
|
||||
| { kind: "up-to-date" }
|
||||
| { kind: "found"; latestVersion: string };
|
||||
|
||||
/**
|
||||
* Classifies a manual check result. Order matters: `disabled` means no lookup ran, `error`
|
||||
* means the lookup ran and failed (the response is fail-soft, not an exception), and only a
|
||||
* result that names the newer release counts as `found` — updateAvailable without a version
|
||||
* would leave the row and the toast with nothing to show.
|
||||
*/
|
||||
export function updateCheckOutcome(res: UpdateCheckResponse): UpdateCheckOutcome {
|
||||
if (res.disabled === true) return { kind: "disabled" };
|
||||
if (res.error !== undefined) return { kind: "failed" };
|
||||
if (res.updateAvailable && res.latestVersion !== null) {
|
||||
return { kind: "found", latestVersion: res.latestVersion };
|
||||
}
|
||||
return { kind: "up-to-date" };
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
version: VersionResponse | null;
|
||||
update: UpdateCheckResponse | null;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* updateCheckOutcome unit tests: the manual "check for updates" action turns one fail-soft
|
||||
* server response into exactly one user notice — disabled beats error, error beats found,
|
||||
* and "found" requires the release to be named so the toast and row have something to show.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UpdateCheckResponse } from "@prismshadow/penguin-server/api";
|
||||
import { updateCheckOutcome } from "../src/lib/use-version-info";
|
||||
|
||||
function response(overrides: Partial<UpdateCheckResponse>): UpdateCheckResponse {
|
||||
return {
|
||||
currentVersion: "0.1.5",
|
||||
buildDate: null,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
releaseUrl: null,
|
||||
publishedAt: null,
|
||||
checkedAt: "2026-08-02T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("updateCheckOutcome", () => {
|
||||
it("classifies an up-to-date result", () => {
|
||||
expect(updateCheckOutcome(response({ latestVersion: "0.1.5" }))).toEqual({
|
||||
kind: "up-to-date",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a newer release with its version", () => {
|
||||
expect(updateCheckOutcome(response({ latestVersion: "0.2.0", updateAvailable: true }))).toEqual(
|
||||
{ kind: "found", latestVersion: "0.2.0" },
|
||||
);
|
||||
});
|
||||
|
||||
it("updateAvailable without a named release falls back to up-to-date, never a blank notice", () => {
|
||||
expect(updateCheckOutcome(response({ updateAvailable: true }))).toEqual({
|
||||
kind: "up-to-date",
|
||||
});
|
||||
});
|
||||
|
||||
it("a failed lookup wins over updateAvailable", () => {
|
||||
expect(
|
||||
updateCheckOutcome(
|
||||
response({ error: "network", updateAvailable: true, latestVersion: "0.2.0" }),
|
||||
),
|
||||
).toEqual({ kind: "failed" });
|
||||
});
|
||||
|
||||
it("disabled wins over everything — no lookup ran", () => {
|
||||
expect(
|
||||
updateCheckOutcome(response({ disabled: true, error: "network", updateAvailable: true })),
|
||||
).toEqual({ kind: "disabled" });
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/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")"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/sh
|
||||
# Wrap each program payload with its checksum and native installer into the canonical Release
|
||||
# artifact — the only package shape a Release publishes:
|
||||
#
|
||||
# penguin-<target>.tar.gz = install.sh + payload.tar.gz + payload.tar.gz.sha256
|
||||
# penguin-win32-x64.zip = install.cmd + install.ps1 + payload.zip + payload.zip.sha256
|
||||
#
|
||||
# The same bundle serves online installs (downloaded, outer-verified and opened by install.sh /
|
||||
# install.ps1) and offline installs (transfer one file, extract once, run the bundled
|
||||
# installer, which verifies and installs the sibling payload with no network). The outer layer
|
||||
# stays flat so extracting it never creates deep paths; only the installer expands the payload,
|
||||
# inside its short staging directory.
|
||||
#
|
||||
# Usage: package-release-bundles.sh <payload-dir> [output-dir]
|
||||
# <payload-dir> must contain <target>.tar.gz for linux-x64, linux-arm64, darwin-x64,
|
||||
# darwin-arm64 and universal, plus win32-x64.zip; relative paths resolve from the repo root.
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
|
||||
PAYLOAD_INPUT="${1:?usage: package-release-bundles.sh <payload-dir> [output-dir]}"
|
||||
OUTPUT_INPUT="${2:-dist-artifacts}"
|
||||
|
||||
resolve_dir() {
|
||||
case "$1" in
|
||||
/*) printf '%s\n' "$1" ;;
|
||||
*) printf '%s\n' "$ROOT_DIR/$1" ;;
|
||||
esac
|
||||
}
|
||||
PAYLOAD_DIR="$(resolve_dir "$PAYLOAD_INPUT")"
|
||||
OUTPUT_DIR="$(resolve_dir "$OUTPUT_INPUT")"
|
||||
|
||||
[ -d "$PAYLOAD_DIR" ] || {
|
||||
echo "error: payload directory not found: $PAYLOAD_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() {
|
||||
[ -f "$PAYLOAD_DIR/$1" ] || {
|
||||
echo "error: missing payload: $PAYLOAD_DIR/$1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 universal; do
|
||||
payload="$target.tar.gz"
|
||||
output="$OUTPUT_DIR/penguin-$target.tar.gz"
|
||||
bundle="$WORK_DIR/$target"
|
||||
require_payload "$payload"
|
||||
mkdir -p "$bundle"
|
||||
cp "$PAYLOAD_DIR/$payload" "$bundle/payload.tar.gz"
|
||||
write_sha256 "$bundle/payload.tar.gz"
|
||||
cp "$ROOT_DIR/install.sh" "$bundle/install.sh"
|
||||
chmod +x "$bundle/install.sh"
|
||||
rm -f "$output" "$output.sha256"
|
||||
tar -czf "$output" -C "$bundle" .
|
||||
write_sha256 "$output"
|
||||
echo "Created $(basename "$output")"
|
||||
done
|
||||
|
||||
payload="win32-x64.zip"
|
||||
output="$OUTPUT_DIR/penguin-win32-x64.zip"
|
||||
bundle="$WORK_DIR/win32-x64"
|
||||
require_payload "$payload"
|
||||
mkdir -p "$bundle"
|
||||
cp "$PAYLOAD_DIR/$payload" "$bundle/payload.zip"
|
||||
write_sha256 "$bundle/payload.zip"
|
||||
cp "$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,189 @@
|
||||
# Hermetic Windows installer tests with tiny fixtures: offline upgrade rollback, canonical
|
||||
# bundle installs (local, sibling and online), both checksum layers, no-fallback failures, and
|
||||
# pre-0.1.6 legacy archives from pinned versions.
|
||||
[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-installer-tests-$PID"
|
||||
$OriginalPath = $env:Path
|
||||
$OriginalOs = $env:OS
|
||||
$Fixture = @{
|
||||
Requests = [Collections.Generic.List[string]]::new()
|
||||
Mode = "canonical"
|
||||
GoodBundle = $null
|
||||
BadInnerBundle = $null
|
||||
LegacyArchive = $null
|
||||
}
|
||||
$global:PenguinInstallerFixture = $Fixture
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# Serves release assets for the online cases. The new installer never inspects HTTP status
|
||||
# codes, so failure modes are plain throws.
|
||||
function global:Invoke-WebRequest {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Uri,
|
||||
[Parameter(Mandatory = $true)][string]$OutFile,
|
||||
[switch]$UseBasicParsing
|
||||
)
|
||||
$f = $global:PenguinInstallerFixture
|
||||
$f.Requests.Add($Uri)
|
||||
if ($f.Mode -eq "404") { throw "fixture 404: $Uri" }
|
||||
if ($f.Mode -eq "network") { throw "fixture network failure: $Uri" }
|
||||
switch -Wildcard ($Uri) {
|
||||
"*/penguin-win32-x64.zip.sha256" {
|
||||
switch ($f.Mode) {
|
||||
"outer-sha-mismatch" {
|
||||
("0" * 64) + " penguin-win32-x64.zip" | Set-Content -LiteralPath $OutFile -Encoding ascii
|
||||
}
|
||||
"inner-sha-mismatch" { Copy-Item -LiteralPath "$($f.BadInnerBundle).sha256" -Destination $OutFile }
|
||||
"legacy" { Copy-Item -LiteralPath "$($f.LegacyArchive).sha256" -Destination $OutFile }
|
||||
default { Copy-Item -LiteralPath "$($f.GoodBundle).sha256" -Destination $OutFile }
|
||||
}
|
||||
}
|
||||
"*/penguin-win32-x64.zip" {
|
||||
switch ($f.Mode) {
|
||||
"inner-sha-mismatch" { Copy-Item -LiteralPath $f.BadInnerBundle -Destination $OutFile }
|
||||
"legacy" { Copy-Item -LiteralPath $f.LegacyArchive -Destination $OutFile }
|
||||
default { Copy-Item -LiteralPath $f.GoodBundle -Destination $OutFile }
|
||||
}
|
||||
}
|
||||
default { throw "unexpected fixture request: $Uri" }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-OnlineCase(
|
||||
[string]$Name,
|
||||
[string]$Mode,
|
||||
[string]$Version,
|
||||
[bool]$ShouldSucceed,
|
||||
[int]$ExpectedRequests
|
||||
) {
|
||||
$Fixture.Mode = $Mode
|
||||
$Fixture.Requests.Clear()
|
||||
$InstallDir = Join-Path $WorkDir "$Name-install"
|
||||
$Arguments = @{ InstallDir = $InstallDir }
|
||||
if ($Version) { $Arguments.Version = $Version }
|
||||
$Succeeded = $true
|
||||
try { & $Installer @Arguments *>&1 | Out-Null } catch { $Succeeded = $false }
|
||||
Assert-True ($Succeeded -eq $ShouldSucceed) "$Name returned an unexpected result"
|
||||
Assert-True ($Fixture.Requests.Count -eq $ExpectedRequests) `
|
||||
"$Name made $($Fixture.Requests.Count) requests, expected $ExpectedRequests"
|
||||
[PSCustomObject]@{ InstallDir = $InstallDir; Requests = @($Fixture.Requests) }
|
||||
}
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
|
||||
# Keep the fixture tests away from the runner's user registry Path.
|
||||
$env:OS = "PenguinInstallerFixtureTest"
|
||||
|
||||
# --- Offline program archive: good install, then a failing upgrade must roll back. ---
|
||||
$InstallDir = Join-Path $WorkDir "offline-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"
|
||||
|
||||
# --- Canonical bundle fixtures: flat outer layer sealing payload.zip + checksum + installers. ---
|
||||
$BundleDir = Join-Path $WorkDir "bundle"
|
||||
New-Item -ItemType Directory -Path $BundleDir | Out-Null
|
||||
Copy-Item $GoodArchive (Join-Path $BundleDir "payload.zip")
|
||||
$PayloadHash = (Get-FileHash -LiteralPath (Join-Path $BundleDir "payload.zip") -Algorithm SHA256).Hash
|
||||
"$PayloadHash payload.zip" |
|
||||
Set-Content -LiteralPath (Join-Path $BundleDir "payload.zip.sha256") -Encoding ascii
|
||||
Copy-Item (Join-Path $RepoRoot "install.ps1"), (Join-Path $RepoRoot "install.cmd") $BundleDir
|
||||
$Fixture.GoodBundle = Join-Path $WorkDir "penguin-win32-x64.zip"
|
||||
Compress-Archive -Path (Join-Path $BundleDir "*") -DestinationPath $Fixture.GoodBundle -CompressionLevel Fastest
|
||||
$GoodHash = (Get-FileHash $Fixture.GoodBundle -Algorithm SHA256).Hash
|
||||
"$GoodHash penguin-win32-x64.zip" |
|
||||
Set-Content -LiteralPath "$($Fixture.GoodBundle).sha256" -Encoding ascii
|
||||
|
||||
$BadBundleDir = Join-Path $WorkDir "bad-bundle"
|
||||
Copy-Item $BundleDir $BadBundleDir -Recurse
|
||||
(("0" * 64) + " payload.zip") |
|
||||
Set-Content (Join-Path $BadBundleDir "payload.zip.sha256") -Encoding ascii
|
||||
$Fixture.BadInnerBundle = Join-Path $WorkDir "bad-inner.zip"
|
||||
Compress-Archive -Path (Join-Path $BadBundleDir "*") -DestinationPath $Fixture.BadInnerBundle
|
||||
$BadHash = (Get-FileHash $Fixture.BadInnerBundle -Algorithm SHA256).Hash
|
||||
"$BadHash penguin-win32-x64.zip" |
|
||||
Set-Content -LiteralPath "$($Fixture.BadInnerBundle).sha256" -Encoding ascii
|
||||
|
||||
$Fixture.LegacyArchive = $GoodArchive
|
||||
|
||||
# --- Local bundle via -ArchivePath: opened flat, sealed payload checksum verified. ---
|
||||
$BundleInstall = Join-Path $WorkDir "bundle-install"
|
||||
& $Installer -InstallDir $BundleInstall -ArchivePath $Fixture.GoodBundle *>&1 | Out-Null
|
||||
$Version = & (Join-Path $BundleInstall "bin\penguin.cmd") --version
|
||||
Assert-True ($Version -eq "fixture-old") "local bundle install did not produce a working command"
|
||||
|
||||
# --- Extracted bundle: install.ps1 next to payload.zip installs it with no network. ---
|
||||
$SiblingDir = Join-Path $WorkDir "sibling"
|
||||
New-Item -ItemType Directory -Path $SiblingDir | Out-Null
|
||||
Expand-Archive -LiteralPath $Fixture.GoodBundle -DestinationPath $SiblingDir
|
||||
$SiblingInstall = Join-Path $WorkDir "sibling-install"
|
||||
$Fixture.Requests.Clear()
|
||||
& (Join-Path $SiblingDir "install.ps1") -InstallDir $SiblingInstall *>&1 | Out-Null
|
||||
Assert-True ($Fixture.Requests.Count -eq 0) "sibling install unexpectedly touched the network"
|
||||
$Version = & (Join-Path $SiblingInstall "bin\penguin.cmd") --version
|
||||
Assert-True ($Version -eq "fixture-old") "sibling install did not produce a working command"
|
||||
|
||||
# --- Online cases. ---
|
||||
$canonical = Invoke-OnlineCase "canonical" "canonical" "" $true 2
|
||||
Assert-True ($canonical.Requests[0] -like "*/releases/latest/download/penguin-win32-x64.zip") `
|
||||
"canonical did not request the canonical bundle"
|
||||
$Version = & (Join-Path $canonical.InstallDir "bin\penguin.cmd") --version
|
||||
Assert-True ($Version -eq "fixture-old") "canonical bundle was not installed"
|
||||
Invoke-OnlineCase "outer-mismatch" "outer-sha-mismatch" "" $false 2 | Out-Null
|
||||
Invoke-OnlineCase "inner-mismatch" "inner-sha-mismatch" "" $false 2 | Out-Null
|
||||
Invoke-OnlineCase "latest-404" "404" "" $false 1 | Out-Null
|
||||
Invoke-OnlineCase "pinned-network" "network" "v0.1.4" $false 1 | Out-Null
|
||||
$pinned = Invoke-OnlineCase "pinned-legacy" "legacy" "v0.1.4" $true 2
|
||||
Assert-True ($pinned.Requests[0] -like "*/releases/download/v0.1.4/penguin-win32-x64.zip") `
|
||||
"pinned legacy did not request the pinned asset"
|
||||
|
||||
Write-Host "Windows installer bundle, offline, rollback and online tests passed."
|
||||
} finally {
|
||||
$env:Path = $OriginalPath
|
||||
$env:OS = $OriginalOs
|
||||
Remove-Item Function:\Invoke-WebRequest -ErrorAction SilentlyContinue
|
||||
Remove-Variable PenguinInstallerFixture -Scope Global -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $WorkDir) { Remove-Item -LiteralPath $WorkDir -Recurse -Force }
|
||||
}
|
||||
Executable
+305
@@ -0,0 +1,305 @@
|
||||
#!/bin/sh
|
||||
# Hermetic installer tests with tiny fixtures: canonical bundle layout, offline install with no
|
||||
# network, POSIX upgrade rollback, and the online download flow through a stubbed curl
|
||||
# (checksum layers, no-fallback failures, pre-0.1.6 legacy archives from pinned versions).
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
ARTIFACT_DIR="$WORK_DIR/artifacts"
|
||||
PAYLOAD_DIR="$WORK_DIR/payloads"
|
||||
STUB_BIN="$WORK_DIR/bin"
|
||||
TEST_HOME="$WORK_DIR/home"
|
||||
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-src"
|
||||
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 'echo %s\n' "${4:-fixture-old}"
|
||||
} > "$payload/penguin/bin/penguin"
|
||||
fi
|
||||
chmod +x "$payload/penguin/bin/penguin"
|
||||
printf '%s\n' fixture > "$payload/penguin/lib/fixture.txt"
|
||||
mkdir -p "$payload/penguin/lib/vendor"
|
||||
printf '%s\n' vendored > "$payload/penguin/lib/vendor/data.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
|
||||
}
|
||||
|
||||
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" "$PAYLOAD_DIR" "$STUB_BIN" "$TEST_HOME"
|
||||
|
||||
case "$(uname -s):$(uname -m)" in
|
||||
Linux:x86_64) HOST_TARGET="linux-x64" ;;
|
||||
Linux:aarch64) HOST_TARGET="linux-arm64" ;;
|
||||
Darwin:x86_64) HOST_TARGET="darwin-x64" ;;
|
||||
Darwin:arm64) HOST_TARGET="darwin-arm64" ;;
|
||||
*) fail_test "unsupported fixture platform" ;;
|
||||
esac
|
||||
HOST_ASSET="penguin-$HOST_TARGET.tar.gz"
|
||||
|
||||
# --- Build fixture payloads and package them exactly like the release workflow. ---
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 universal; do
|
||||
make_posix_payload "$target" "$PAYLOAD_DIR/$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 "$PAYLOAD_DIR/win32-x64.zip" penguin)
|
||||
|
||||
sh "$ROOT_DIR/scripts/package-release-bundles.sh" "$PAYLOAD_DIR" "$ARTIFACT_DIR"
|
||||
|
||||
# --- Canonical layout: flat bundles, exact member set, byte-identical installers, both
|
||||
# checksum layers valid. ---
|
||||
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 universal; do
|
||||
bundle="$ARTIFACT_DIR/penguin-$target.tar.gz"
|
||||
[ -f "$bundle" ] || fail_test "missing $(basename "$bundle")"
|
||||
(cd "$ARTIFACT_DIR" && sha256sum -c "$(basename "$bundle").sha256" >/dev/null) \
|
||||
|| fail_test "outer checksum failed for $(basename "$bundle")"
|
||||
members="$(tar -tzf "$bundle" | sed 's#^\./##' | sed '/^$/d' | LC_ALL=C sort)"
|
||||
expected="$(printf '%s\n' install.sh payload.tar.gz payload.tar.gz.sha256 | LC_ALL=C sort)"
|
||||
[ "$members" = "$expected" ] || fail_test "$(basename "$bundle") has an unexpected layout"
|
||||
extracted="$WORK_DIR/layout-$target"
|
||||
mkdir -p "$extracted"
|
||||
tar -xzf "$bundle" -C "$extracted"
|
||||
[ -x "$extracted/install.sh" ] || fail_test "$(basename "$bundle") installer is not executable"
|
||||
cmp -s "$ROOT_DIR/install.sh" "$extracted/install.sh" \
|
||||
|| fail_test "$(basename "$bundle") installer differs from the repository installer"
|
||||
(cd "$extracted" && sha256sum -c payload.tar.gz.sha256 >/dev/null) \
|
||||
|| fail_test "$(basename "$bundle") payload checksum failed"
|
||||
cmp -s "$PAYLOAD_DIR/$target.tar.gz" "$extracted/payload.tar.gz" \
|
||||
|| fail_test "$(basename "$bundle") payload differs from its input"
|
||||
done
|
||||
|
||||
windows_bundle="$ARTIFACT_DIR/penguin-win32-x64.zip"
|
||||
[ -f "$windows_bundle" ] || fail_test "missing penguin-win32-x64.zip"
|
||||
(cd "$ARTIFACT_DIR" && sha256sum -c penguin-win32-x64.zip.sha256 >/dev/null) \
|
||||
|| fail_test "outer checksum failed for penguin-win32-x64.zip"
|
||||
members="$(unzip -Z1 "$windows_bundle" | LC_ALL=C sort)"
|
||||
expected="$(printf '%s\n' install.cmd install.ps1 payload.zip payload.zip.sha256 | LC_ALL=C sort)"
|
||||
[ "$members" = "$expected" ] || fail_test "penguin-win32-x64.zip has an unexpected layout"
|
||||
extracted="$WORK_DIR/layout-win32-x64"
|
||||
mkdir -p "$extracted"
|
||||
(cd "$extracted" && unzip -q "$windows_bundle")
|
||||
cmp -s "$ROOT_DIR/install.ps1" "$extracted/install.ps1" \
|
||||
|| fail_test "Windows bundle installer differs from the repository installer"
|
||||
cmp -s "$ROOT_DIR/install.cmd" "$extracted/install.cmd" \
|
||||
|| fail_test "Windows bundle install.cmd differs from the repository entry point"
|
||||
(cd "$extracted" && sha256sum -c payload.zip.sha256 >/dev/null) \
|
||||
|| fail_test "Windows bundle payload checksum failed"
|
||||
cmp -s "$PAYLOAD_DIR/win32-x64.zip" "$extracted/payload.zip" \
|
||||
|| fail_test "Windows bundle payload differs from its input"
|
||||
|
||||
# --- Offline install: extract the bundle once and run its installer, with a curl that always
|
||||
# fails first on PATH — the offline path must never touch the network. ---
|
||||
cat > "$STUB_BIN/curl" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "unexpected network access: curl $*" >&2
|
||||
exit 7
|
||||
EOF
|
||||
chmod +x "$STUB_BIN/curl"
|
||||
|
||||
OFFLINE_DIR="$WORK_DIR/offline"
|
||||
OFFLINE_INSTALL="$WORK_DIR/offline-install"
|
||||
mkdir -p "$OFFLINE_DIR"
|
||||
tar -xzf "$ARTIFACT_DIR/$HOST_ASSET" -C "$OFFLINE_DIR"
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$OFFLINE_INSTALL" PATH="$STUB_BIN:$PATH" \
|
||||
sh "$OFFLINE_DIR/install.sh" >/dev/null \
|
||||
|| fail_test "offline install from the extracted bundle failed"
|
||||
[ "$("$OFFLINE_INSTALL/bin/penguin" --version)" = "fixture-old" ] \
|
||||
|| fail_test "offline install did not produce a working command"
|
||||
|
||||
# A corrupted extracted payload must be rejected by the sealed checksum.
|
||||
CORRUPT_DIR="$WORK_DIR/offline-corrupt"
|
||||
mkdir -p "$CORRUPT_DIR"
|
||||
tar -xzf "$ARTIFACT_DIR/$HOST_ASSET" -C "$CORRUPT_DIR"
|
||||
printf 'corruption' >> "$CORRUPT_DIR/payload.tar.gz"
|
||||
set +e
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$WORK_DIR/offline-corrupt-install" PATH="$STUB_BIN:$PATH" \
|
||||
sh "$CORRUPT_DIR/install.sh" >/dev/null 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[ "$status" -ne 0 ] || fail_test "corrupted offline payload was not rejected"
|
||||
|
||||
# --- Local archives: the canonical bundle and a bare payload both install; a failing upgrade
|
||||
# rolls back to the previous installation. ---
|
||||
LOCAL_INSTALL="$TEST_HOME/.penguin"
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$LOCAL_INSTALL" \
|
||||
sh "$ROOT_DIR/install.sh" --archive "$ARTIFACT_DIR/$HOST_ASSET" >/dev/null \
|
||||
|| fail_test "--archive with the canonical bundle failed"
|
||||
|
||||
payload_archive="$WORK_DIR/payload.tar.gz"
|
||||
cp "$PAYLOAD_DIR/$HOST_TARGET.tar.gz" "$payload_archive"
|
||||
write_sha256 "$payload_archive"
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$LOCAL_INSTALL" \
|
||||
sh "$ROOT_DIR/install.sh" --archive "$payload_archive" >/dev/null \
|
||||
|| fail_test "--archive with a bare payload failed"
|
||||
|
||||
failure_archive="$WORK_DIR/final-failure.tar.gz"
|
||||
make_posix_payload "$HOST_TARGET" "$failure_archive" final-failure
|
||||
write_sha256 "$failure_archive"
|
||||
set +e
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$LOCAL_INSTALL" \
|
||||
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"
|
||||
[ "$("$LOCAL_INSTALL/bin/penguin" --version)" = "fixture-old" ] \
|
||||
|| fail_test "previous POSIX installation was not restored"
|
||||
|
||||
# --- Pinned-directory upgrade: emulate a filesystem that refuses to rename in-use directories
|
||||
# (overlayfs reports EBUSY when `penguin update` replaces the very lib/ its own process runs
|
||||
# from). mv/rmdir stubs refuse directory renames that touch the installed lib, forcing
|
||||
# relocate_dir through its per-entry and copy fallbacks and the husk-reuse path. ---
|
||||
PINNED_LIB="$LOCAL_INSTALL/lib"
|
||||
export PINNED_LIB
|
||||
cat > "$STUB_BIN/mv" <<'EOF'
|
||||
#!/bin/sh
|
||||
if [ -d "$1" ]; then
|
||||
case "$1" in
|
||||
"$PINNED_LIB" | "$PINNED_LIB"/*)
|
||||
echo "mv: cannot move '$1': Device or resource busy" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
exec /bin/mv "$@"
|
||||
EOF
|
||||
cat > "$STUB_BIN/rmdir" <<'EOF'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
"$PINNED_LIB")
|
||||
echo "rmdir: failed to remove '$1': Device or resource busy" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exec /bin/rmdir "$@"
|
||||
EOF
|
||||
chmod +x "$STUB_BIN/mv" "$STUB_BIN/rmdir"
|
||||
|
||||
pinned_archive="$WORK_DIR/pinned-upgrade.tar.gz"
|
||||
make_posix_payload "$HOST_TARGET" "$pinned_archive" success fixture-upgraded
|
||||
write_sha256 "$pinned_archive"
|
||||
HOME="$TEST_HOME" PENGUIN_INSTALL_DIR="$LOCAL_INSTALL" PATH="$STUB_BIN:$PATH" \
|
||||
sh "$ROOT_DIR/install.sh" --archive "$pinned_archive" >/dev/null \
|
||||
|| fail_test "upgrade with a pinned lib directory failed"
|
||||
rm -f "$STUB_BIN/mv" "$STUB_BIN/rmdir"
|
||||
[ "$("$LOCAL_INSTALL/bin/penguin" --version)" = "fixture-upgraded" ] \
|
||||
|| fail_test "pinned-lib upgrade did not install the new version"
|
||||
[ -f "$LOCAL_INSTALL/lib/vendor/data.txt" ] \
|
||||
|| fail_test "pinned-lib upgrade lost the copied lib subdirectory"
|
||||
[ -z "$(ls -A "$LOCAL_INSTALL" | grep -E '^\.(old|staging)\.' || :)" ] \
|
||||
|| fail_test "pinned-lib upgrade left staging or backup directories behind"
|
||||
|
||||
# --- Online flow through a stubbed curl. The canonical bundle is served for current releases;
|
||||
# MODE=legacy serves a pre-0.1.6 program archive, which must still install from a pinned
|
||||
# version. Checksum failures and download failures must fail without any fallback. ---
|
||||
LEGACY_ARCHIVE="$WORK_DIR/legacy.tar.gz"
|
||||
make_posix_payload "$HOST_TARGET" "$LEGACY_ARCHIVE"
|
||||
write_sha256 "$LEGACY_ARCHIVE"
|
||||
|
||||
BAD_DIR="$WORK_DIR/bad-bundle"
|
||||
mkdir -p "$BAD_DIR"
|
||||
tar -xzf "$ARTIFACT_DIR/$HOST_ASSET" -C "$BAD_DIR"
|
||||
printf '%064d payload.tar.gz\n' 0 > "$BAD_DIR/payload.tar.gz.sha256"
|
||||
BAD_BUNDLE="$WORK_DIR/bad-bundle.tar.gz"
|
||||
tar -czf "$BAD_BUNDLE" -C "$BAD_DIR" .
|
||||
write_sha256 "$BAD_BUNDLE"
|
||||
|
||||
cat > "$STUB_BIN/curl" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
output=""
|
||||
url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) output="$2"; shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "$REQUEST_LOG"
|
||||
base="${url##*/}"
|
||||
case "$MODE:$base" in
|
||||
404:penguin-*) exit 22 ;;
|
||||
network:penguin-*) exit 7 ;;
|
||||
outer-sha-mismatch:penguin-*.sha256) printf '%064d %s\n' 0 "${base%.sha256}" > "$output" ;;
|
||||
outer-sha-mismatch:penguin-*) cp "$ARTIFACT_DIR/$base" "$output" ;;
|
||||
inner-sha-mismatch:penguin-*.sha256) cp "$BAD_BUNDLE.sha256" "$output" ;;
|
||||
inner-sha-mismatch:penguin-*) cp "$BAD_BUNDLE" "$output" ;;
|
||||
legacy:penguin-*.sha256) cp "$LEGACY_ARCHIVE.sha256" "$output" ;;
|
||||
legacy:penguin-*) cp "$LEGACY_ARCHIVE" "$output" ;;
|
||||
canonical:penguin-*.sha256) cp "$ARTIFACT_DIR/$base" "$output" ;;
|
||||
canonical:penguin-*) cp "$ARTIFACT_DIR/$base" "$output" ;;
|
||||
*) echo "unexpected fixture request: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$STUB_BIN/curl"
|
||||
export ARTIFACT_DIR BAD_BUNDLE LEGACY_ARCHIVE
|
||||
|
||||
run_online_case() {
|
||||
name="$1"
|
||||
mode="$2"
|
||||
version="$3"
|
||||
expected="$4"
|
||||
expected_requests="$5"
|
||||
CASE_LOG="$WORK_DIR/$name.log"
|
||||
CASE_INSTALL="$WORK_DIR/$name-install"
|
||||
: > "$CASE_LOG"
|
||||
set +e
|
||||
REQUEST_LOG="$CASE_LOG" MODE="$mode" PATH="$STUB_BIN:$PATH" \
|
||||
HOME="$WORK_DIR/$name-home" PENGUIN_INSTALL_DIR="$CASE_INSTALL" \
|
||||
PENGUIN_VERSION="$version" sh "$ROOT_DIR/install.sh" >/dev/null 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$expected" = "success" ]; then
|
||||
[ "$status" -eq 0 ] || fail_test "$name unexpectedly failed"
|
||||
else
|
||||
[ "$status" -ne 0 ] || fail_test "$name unexpectedly succeeded"
|
||||
fi
|
||||
[ "$(wc -l < "$CASE_LOG" | tr -d ' ')" -eq "$expected_requests" ] \
|
||||
|| fail_test "$name made an unexpected number of requests"
|
||||
}
|
||||
|
||||
run_online_case canonical canonical "" success 2
|
||||
[ "$("$WORK_DIR/canonical-install/bin/penguin" --version)" = "fixture-old" ] \
|
||||
|| fail_test "canonical online install did not produce a working command"
|
||||
grep -q "/releases/latest/download/$HOST_ASSET\$" "$WORK_DIR/canonical.log" \
|
||||
|| fail_test "canonical did not request the canonical bundle"
|
||||
run_online_case outer-mismatch outer-sha-mismatch "" failure 2
|
||||
run_online_case inner-mismatch inner-sha-mismatch "" failure 2
|
||||
run_online_case latest-404 404 "" failure 1
|
||||
run_online_case pinned-network network v0.1.4 failure 1
|
||||
run_online_case pinned-legacy legacy v0.1.4 success 2
|
||||
grep -q "/releases/download/v0.1.4/$HOST_ASSET\$" "$WORK_DIR/pinned-legacy.log" \
|
||||
|| fail_test "pinned legacy did not request the pinned asset"
|
||||
|
||||
echo "Installer bundle, offline, rollback and online tests passed."
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/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."
|
||||
@@ -1,65 +0,0 @@
|
||||
# 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