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