#!/bin/sh # PenguinHarness one-line installer. # # curl -fsSL https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh | sh # # Options: # PENGUIN_VERSION=vX.Y.Z choose a version (same as --version vX.Y.Z); a published Release # installer defaults to its own version, an unstamped source copy to latest # PENGUIN_INSTALL_DIR= install dir; default ~/.penguin # PENGUIN_ARCHIVE= install a local Release archive without network access (same as --archive ) # PENGUIN_DOWNLOAD_SOURCE=auto|oss|github choose the online source; default auto (OSS, then same-version GitHub) # PENGUIN_DOWNLOAD_BASE_URL= exact online asset directory selected by the stable forwarder # PENGUIN_DOWNLOAD_FALLBACK_BASE_URL= same-version fallback asset directory # --universal install the universal package (no bundled Node runtime; needs system Node >= 24) # # Each Release attaches exactly one artifact per target: penguin-.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 set -eu REPO="https://github.com/Prism-Shadow/penguin-harness" OSS_ORIGIN="https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com" OSS_RELEASE_ROOT="$OSS_ORIGIN/releases" GITHUB_RELEASE_ROOT="$REPO/releases/download" GITHUB_LATEST_BASE="$REPO/releases/latest/download" VERSION="${PENGUIN_VERSION:-}" INSTALL_DIR="${PENGUIN_INSTALL_DIR:-$HOME/.penguin}" BIN_DIR="$HOME/.local/bin" UNIVERSAL=0 ARCHIVE="${PENGUIN_ARCHIVE:-}" SOURCE_MODE="${PENGUIN_DOWNLOAD_SOURCE:-auto}" DOWNLOAD_BASE_URL="${PENGUIN_DOWNLOAD_BASE_URL:-}" DOWNLOAD_FALLBACK_BASE_URL="${PENGUIN_DOWNLOAD_FALLBACK_BASE_URL:-}" PAYLOAD_NAME="payload.tar.gz" # The release workflow replaces this token with the immutable tag before publishing both the # standalone installer and the copies sealed inside the Linux, macOS and universal bundles. EMBEDDED_RELEASE_VERSION="__PENGUIN_RELEASE_VERSION__" fail() { echo "error: $1" >&2 exit 1 } validate_https_url() { case "$2" in https://*) ;; *) fail "$1 must be an absolute HTTPS URL" ;; esac } is_release_tag() { case "$1" in v[0-9A-Za-z]* ) ;; *) return 1 ;; esac case "$1" in *[!0-9A-Za-z._-]*) return 1 ;; esac return 0 } validate_release_tag() { is_release_tag "$1" || fail "invalid release version: $1" } download_source_label() { case "$1" in https://*.aliyuncs.com/*) printf '%s\n' "OSS mirror" ;; https://github.com/*) printf '%s\n' "GitHub" ;; *) printf '%s\n' "configured mirror" ;; esac } # --- Parse args (also passable via curl | sh -s -- --universal) --- while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || fail "--version requires a value (e.g. --version v1.0.0)" VERSION="$2" shift 2 ;; --universal) UNIVERSAL=1 shift ;; --archive) [ $# -ge 2 ] || fail "--archive requires a path to a Release archive" ARCHIVE="$2" shift 2 ;; *) fail "unknown option: $1" ;; esac done # --- Detect platform: Linux/Darwin x64/arm64; other platforms should use the universal package --- TARGET="universal" if [ "$UNIVERSAL" -eq 0 ]; then case "$(uname -s)" in Linux) os="linux" ;; Darwin) os="darwin" ;; *) fail "unsupported OS: $(uname -s). Install Node.js >= 24, then re-run with --universal." ;; esac case "$(uname -m)" in x86_64) arch="x64" ;; aarch64 | arm64) arch="arm64" ;; *) fail "unsupported architecture: $(uname -m). Install Node.js >= 24, then re-run with --universal." ;; esac TARGET="$os-$arch" fi ASSET="penguin-$TARGET.tar.gz" if [ -n "$ARCHIVE" ] && [ -n "$VERSION" ]; then fail "--archive/PENGUIN_ARCHIVE cannot be combined with --version/PENGUIN_VERSION" fi if [ -n "$VERSION" ]; then validate_release_tag "$VERSION" fi case "$SOURCE_MODE" in auto | oss | github) ;; *) fail "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github" ;; esac RESOLVED_RELEASE_VERSION="$VERSION" if [ -z "$RESOLVED_RELEASE_VERSION" ] && is_release_tag "$EMBEDDED_RELEASE_VERSION"; then RESOLVED_RELEASE_VERSION="$EMBEDDED_RELEASE_VERSION" fi if [ -n "$DOWNLOAD_BASE_URL" ]; then DOWNLOAD_BASE_URL="${DOWNLOAD_BASE_URL%/}" validate_https_url PENGUIN_DOWNLOAD_BASE_URL "$DOWNLOAD_BASE_URL" fi if [ -n "$DOWNLOAD_FALLBACK_BASE_URL" ]; then DOWNLOAD_FALLBACK_BASE_URL="${DOWNLOAD_FALLBACK_BASE_URL%/}" validate_https_url PENGUIN_DOWNLOAD_FALLBACK_BASE_URL "$DOWNLOAD_FALLBACK_BASE_URL" fi # --- Universal package precheck: system Node >= 24 (platform packages bundle the runtime, so exempt) --- if [ "$UNIVERSAL" -eq 1 ]; then command -v node >/dev/null 2>&1 \ || fail "the universal package needs Node.js >= 24 on PATH (none found)." node_version="$(node --version)" # e.g. v24.18.0 v="${node_version#v}" major="${v%%.*}" if [ "$major" -lt 24 ]; then fail "the universal package needs Node.js >= 24, found $node_version." fi fi TMP="$(mktemp -d)" STAGING="" OLD_DIR="" 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 # 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 relocate_dir "$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 } 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 # 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." } # Downloads the bundle and its checksum as a pair. Transport failures may try a same-version # fallback; checksum failures are handled afterwards and always abort rather than being hidden # by a different source. download_release_pair() { drp_base="$1" drp_label="$(download_source_label "$drp_base")" echo "Downloading $ASSET from $drp_label ..." rm -f "$ARCHIVE_PATH" "$TMP/$ASSET.sha256" curl -fSL --progress-bar "$drp_base/$ASSET" -o "$ARCHIVE_PATH" \ || return 1 curl -fsSL "$drp_base/$ASSET.sha256" -o "$TMP/$ASSET.sha256" \ || return 1 return 0 } # Resolves the OSS mirror's latest immutable tag. The advertised base URL must exactly match # the expected bucket path so metadata cannot redirect downloads to an arbitrary host. get_oss_latest_tag() { gol_manifest="$1" rm -f "$gol_manifest" curl -fsSL --connect-timeout 3 --max-time 8 "$OSS_ORIGIN/latest.json" -o "$gol_manifest" 2>/dev/null \ || return 1 gol_schema_version="$(sed -n 's/.*"schemaVersion":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$gol_manifest" | head -n 1)" gol_candidate_tag="$(sed -n 's/.*"tag":[[:space:]]*"\([^"]*\)".*/\1/p' "$gol_manifest" | head -n 1)" gol_candidate_base="$(sed -n 's/.*"releaseBaseUrl":[[:space:]]*"\([^"]*\)".*/\1/p' "$gol_manifest" | head -n 1)" [ "$gol_schema_version" = "1" ] \ && is_release_tag "$gol_candidate_tag" \ && [ "$gol_candidate_base" = "$OSS_RELEASE_ROOT/$gol_candidate_tag" ] \ || return 1 printf '%s\n' "$gol_candidate_tag" } # --- 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 : a local installer bundle, or a payload/legacy program archive; # (c) online: download penguin-.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 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" ARCHIVE_NAME="$archive_name" LOCAL_ARCHIVE=1 echo "Using local archive $ARCHIVE_PATH ..." else # (c) Online: explicit forwarder/configured URLs win. Otherwise a stamped Release installer # uses its own immutable version: auto prefers OSS and falls back only to the same GitHub # tag. An unstamped source-tree installer resolves latest.json first so it also locks one # version before downloading assets. FALLBACK_BASE_URL="$DOWNLOAD_FALLBACK_BASE_URL" if [ -n "$DOWNLOAD_BASE_URL" ]; then BASE_URL="$DOWNLOAD_BASE_URL" elif [ "$SOURCE_MODE" = "github" ]; then if [ -n "$RESOLVED_RELEASE_VERSION" ]; then BASE_URL="$GITHUB_RELEASE_ROOT/$RESOLVED_RELEASE_VERSION" else BASE_URL="$GITHUB_LATEST_BASE" fi else SELECTED_TAG="$RESOLVED_RELEASE_VERSION" if [ -z "$SELECTED_TAG" ]; then SELECTED_TAG="$(get_oss_latest_tag "$TMP/latest.json" || :)" fi if [ -n "$SELECTED_TAG" ]; then BASE_URL="$OSS_RELEASE_ROOT/$SELECTED_TAG" if [ "$SOURCE_MODE" = "auto" ] && [ -z "$FALLBACK_BASE_URL" ]; then FALLBACK_BASE_URL="$GITHUB_RELEASE_ROOT/$SELECTED_TAG" fi elif [ "$SOURCE_MODE" = "oss" ]; then fail "the OSS mirror is unavailable or its release metadata is invalid." else BASE_URL="$GITHUB_LATEST_BASE" fi fi ARCHIVE_PATH="$TMP/$ASSET" ARCHIVE_NAME="$ASSET" if ! download_release_pair "$BASE_URL"; then if [ -n "$FALLBACK_BASE_URL" ] && [ "$FALLBACK_BASE_URL" != "$BASE_URL" ]; then echo "Primary download source unavailable; trying $(download_source_label "$FALLBACK_BASE_URL") ..." download_release_pair "$FALLBACK_BASE_URL" \ || fail "download failed from both the primary source and its fallback. Check your network, then retry." else fail "download failed from $(download_source_label "$BASE_URL"). Check the version tag and your network, then retry." fi fi verify_sha256 "$ARCHIVE_PATH" "$TMP/$ASSET.sha256" "Bundle" fi if [ -z "$PAYLOAD_PATH" ]; then if tar -tzf "$ARCHIVE_PATH" 2>/dev/null | head -50 | grep -qE "^(\./)?$PAYLOAD_NAME\$"; then ARCHIVE_SHAPE="bundle" else 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 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 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 "$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 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_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" STAGING="$INSTALL_DIR/.staging.$$" OLD_DIR="$INSTALL_DIR/.old.$$" rm -rf "$STAGING" rm -rf "$OLD_DIR" mkdir -p "$STAGING" 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." # 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 "$INSTALL_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 (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 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." fi fi done [ -x "$INSTALL_DIR/bin/penguin" ] || fail "install incomplete: $INSTALL_DIR/bin/penguin missing." # 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:"*) ;; *) PATH_MISSING=1 ;; esac 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" echo " penguin web # start the Web UI at http://127.0.0.1:7364 (login: admin, initial password printed on first start)" echo " penguin server # headless server (PORT / HOST to override)"