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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user