From e1141ca0100053cd225992552b345c18a7e7494c Mon Sep 17 00:00:00 2001
From: Yaowei Zheng
Date: Mon, 27 Jul 2026 23:34:06 +0800
Subject: [PATCH] feat(core,server,web,cli): show version in the web UI with
update check and admin self-update (#74)
Co-authored-by: Claude Fable 5
---
.github/workflows/release.yml | 10 +
packages/cli/src/commands/serve.ts | 25 +-
packages/cli/src/commands/update.ts | 42 +--
packages/cli/test/serve.test.ts | 19 ++
.../cli/test/update-refusal-markers.test.ts | 59 ++++
packages/core/src/index.ts | 4 +
packages/core/src/internal/version.ts | 41 +++
packages/docs/content/configuration.en.md | 1 +
packages/docs/content/configuration.zh.md | 1 +
packages/docs/content/server-api.en.md | 10 +
packages/docs/content/server-api.zh.md | 10 +
packages/docs/content/web-app.en.md | 4 +
packages/docs/content/web-app.zh.md | 4 +
packages/server/src/api/types.ts | 55 ++++
packages/server/src/app.ts | 10 +
packages/server/src/http/routes/version.ts | 154 +++++++++
.../src/services/update-check-service.ts | 141 +++++++++
packages/server/test/version.test.ts | 298 ++++++++++++++++++
packages/web/src/api/endpoints.ts | 15 +
.../src/components/account/update-dialog.tsx | 103 ++++++
.../web/src/components/layout/sidebar.tsx | 134 +++++++-
packages/web/src/features/chat/draft-view.tsx | 55 ++++
packages/web/src/lib/format.ts | 36 +++
packages/web/src/lib/strings-en.ts | 24 ++
packages/web/src/lib/strings.ts | 24 ++
packages/web/src/lib/use-version-info.ts | 113 +++++++
packages/web/test/format.test.ts | 23 ++
27 files changed, 1375 insertions(+), 40 deletions(-)
create mode 100644 packages/cli/test/update-refusal-markers.test.ts
create mode 100644 packages/core/src/internal/version.ts
create mode 100644 packages/server/src/http/routes/version.ts
create mode 100644 packages/server/src/services/update-check-service.ts
create mode 100644 packages/server/test/version.test.ts
create mode 100644 packages/web/src/components/account/update-dialog.tsx
create mode 100644 packages/web/src/lib/use-version-info.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7af6e01..e59d192 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -88,12 +88,17 @@ jobs:
# Inject the release tag into core's VERSION constant (the source for CLI --version and the install-complete
# message); otherwise artifacts always carry the in-repo dev version and multiple installs can't be told apart.
+ # BUILD_DATE is stamped alongside it with this run's UTC date (the repo keeps null for dev builds).
- name: Stamp release version
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
V="${TAG#v}"
grep -q 'export const VERSION = "' packages/core/src/index.ts
sed -i "s/export const VERSION = \"[^\"]*\"/export const VERSION = \"$V\"/" packages/core/src/index.ts
+ D="$(date -u +%Y-%m-%d)"
+ # BRE: the unescaped | in these patterns is literal (grep/sed default to POSIX BRE, where | is not alternation).
+ grep -q 'export const BUILD_DATE: string | null = ' packages/core/src/index.ts
+ sed -i "s/export const BUILD_DATE: string | null = [^;]*/export const BUILD_DATE: string | null = \"$D\"/" packages/core/src/index.ts
- name: Build (tsup + vite)
run: pnpm build
@@ -279,12 +284,17 @@ jobs:
# Version always comes from the tag: package version and core's VERSION constant are injected together (the
# repo keeps the dev version). All published packages must bump in lockstep -- pnpm publish rewrites every
# workspace:* dep to the dependency's current version, so the versions must match.
+ # BUILD_DATE is stamped alongside VERSION with this run's UTC date (the repo keeps null for dev builds).
- name: Stamp release version
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
V="${TAG#v}"
grep -q 'export const VERSION = "' packages/core/src/index.ts
sed -i "s/export const VERSION = \"[^\"]*\"/export const VERSION = \"$V\"/" packages/core/src/index.ts
+ D="$(date -u +%Y-%m-%d)"
+ # BRE: the unescaped | in these patterns is literal (grep/sed default to POSIX BRE, where | is not alternation).
+ grep -q 'export const BUILD_DATE: string | null = ' packages/core/src/index.ts
+ sed -i "s/export const BUILD_DATE: string | null = [^;]*/export const BUILD_DATE: string | null = \"$D\"/" packages/core/src/index.ts
(cd packages/skills && npm version --no-git-tag-version --allow-same-version "$V")
(cd packages/core && npm version --no-git-tag-version --allow-same-version "$V")
(cd packages/server && npm version --no-git-tag-version --allow-same-version "$V")
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 78133e5..30cf333 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -10,10 +10,12 @@
* in parallel. Port/host priority: command-line option > existing environment variable
* (including .env) > default 7364 / 127.0.0.1. `penguin web` additionally polls until the
* service is ready, prints the URL, and opens a browser per-platform (`--no-open`
- * disables this).
+ * disables this). Before the import, PENGUIN_CLI_ENTRY is exported (when node can re-run
+ * this entry) so the server's admin self-update endpoint can invoke `penguin update`.
* Docs: /docs/cli § "penguin server / penguin web".
*/
import { spawn } from "node:child_process";
+import path from "node:path";
import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core";
import type { Command } from "commander";
import type { Messages } from "../i18n.js";
@@ -55,6 +57,18 @@ export function browserUrl(host: string, port: number): string {
return `http://${target}:${port}/`;
}
+/**
+ * The CLI entry script advertised to the server for the admin self-update endpoint
+ * (POST /api/version/update re-runs it as `node update --yes`), or null when it
+ * must not be advertised. Only entries plain `node` can execute qualify: a dev CLI run
+ * through tsx has a .ts entry that node would reject with a syntax error, and reporting
+ * "unsupported" beats a misleading failure. Exported for unit tests.
+ */
+export function cliEntryFor(argv1: string | undefined): string | null {
+ if (!argv1) return null;
+ return /\.(js|mjs|cjs)$/i.test(argv1) ? path.resolve(argv1) : null;
+}
+
/**
* Sets PORT / HOST then starts the service: the server entry point only reads
* process.env, and its dotenv loading never overrides existing environment variables,
@@ -69,6 +83,15 @@ async function startServer(opts: {
const host = opts.host ?? process.env.HOST ?? DEFAULT_HOST;
process.env.PORT = String(port);
process.env.HOST = host;
+ // Tell the server which CLI entry script launched it: the admin self-update endpoint
+ // (POST /api/version/update) re-runs `node update --yes`. Set before the import
+ // so it is visible however the server captures its environment; when the server was not
+ // started through the CLI (or the entry is not re-runnable by plain node, e.g. a tsx dev
+ // run) the variable stays unset and the endpoint reports "unsupported".
+ const cliEntry = cliEntryFor(process.argv[1]);
+ if (cliEntry !== null) {
+ process.env.PENGUIN_CLI_ENTRY = cliEntry;
+ }
await import("@prismshadow/penguin-server");
return { host, port };
}
diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts
index 16c8b7a..ce00498 100644
--- a/packages/cli/src/commands/update.ts
+++ b/packages/cli/src/commands/update.ts
@@ -58,10 +58,15 @@ import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { realpathSync } from "node:fs";
-import { VERSION } from "@prismshadow/penguin-core";
+import { VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
import type { Command } from "commander";
import type { Messages } from "../i18n.js";
+// The version helpers live in core (internal/version.ts) so the server's update-check
+// endpoint shares them; re-exported because they are part of this module's public,
+// unit-tested surface.
+export { compareVersions, normalizeVersion };
+
/** Repository the released artifacts come from — the same repo install.sh downloads from. */
export const REPO_SLUG = "Prism-Shadow/penguin-harness";
/** Releases API endpoint for the newest published release. */
@@ -165,41 +170,6 @@ export function globalInstallCommand(
return { command: "npm", args: ["install", "-g", spec] };
}
-/** Strips a leading `v` so `v0.1.2` and `0.1.2` are the same input. */
-export function normalizeVersion(tag: string): string {
- return tag.trim().replace(/^v/i, "");
-}
-
-/**
- * Compares two dotted numeric versions: -1 / 0 / 1.
- *
- * Each dot-separated component is read with `Number.parseInt`, which takes the leading digits and
- * ignores the rest: `1abc` is 1, and `2-rc1` is 2. A component with no leading digit at all, or
- * one that is missing entirely, counts as 0 — which is the property that matters, because it means
- * a malformed or truncated tag can never make an upgrade look available.
- *
- * The consequence, stated rather than papered over: suffixes are invisible here, so `0.1.2-rc1`
- * compares *equal* to `0.1.2`. This project tags plain `vX.Y.Z` releases only, and the API this
- * reads (`tag_name` from GitHub Releases) returns those tags, so the case does not arise; carrying
- * a full semver precedence implementation — with its own numeric-vs-alphanumeric identifier rules
- * — to handle tags we do not publish would be more code and more ways to be wrong. If pre-release
- * tags are ever published, this has to become a real semver compare before `--release` can target
- * one.
- */
-export function compareVersions(a: string, b: string): number {
- const parse = (v: string) =>
- normalizeVersion(v)
- .split(".")
- .map((n) => Number.parseInt(n, 10));
- const [x, y] = [parse(a), parse(b)];
- for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
- const l = Number.isFinite(x[i]) ? (x[i] as number) : 0;
- const r = Number.isFinite(y[i]) ? (y[i] as number) : 0;
- if (l !== r) return l < r ? -1 : 1;
- }
- return 0;
-}
-
/**
* Builds the argv and environment for re-running install.sh, preserving the shape of the install
* being upgraded rather than the defaults:
diff --git a/packages/cli/test/serve.test.ts b/packages/cli/test/serve.test.ts
index deffc4a..b67cb23 100644
--- a/packages/cli/test/serve.test.ts
+++ b/packages/cli/test/serve.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import path from "node:path";
import { Command } from "commander";
import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core";
import {
@@ -6,6 +7,7 @@ import {
DEFAULT_PORT,
browserCommand,
browserUrl,
+ cliEntryFor,
registerServeCommands,
resolvePort,
} from "../src/commands/serve.js";
@@ -74,3 +76,20 @@ describe("registerServeCommands (command registration)", () => {
expect(web.opts().open).toBe(true);
});
});
+
+describe("cliEntryFor (the entry advertised for the web self-update)", () => {
+ it("advertises only entries plain node can re-run (.js/.mjs/.cjs), resolved absolute", () => {
+ // Expectations go through path.resolve too: on win32 an absolute POSIX-style input
+ // gains a drive prefix and backslashes, and the contract is "resolved", not a literal.
+ expect(cliEntryFor("/opt/penguin/lib/dist/index.js")).toBe(
+ path.resolve("/opt/penguin/lib/dist/index.js"),
+ );
+ expect(cliEntryFor("/x/cli.MJS")).toBe(path.resolve("/x/cli.MJS"));
+ expect(cliEntryFor("/x/cli.cjs")).toBe(path.resolve("/x/cli.cjs"));
+ });
+ it("refuses a tsx dev entry and a missing argv[1] (the endpoint then reports unsupported)", () => {
+ expect(cliEntryFor("/repo/packages/cli/src/index.ts")).toBeNull();
+ expect(cliEntryFor(undefined)).toBeNull();
+ expect(cliEntryFor("")).toBeNull();
+ });
+});
diff --git a/packages/cli/test/update-refusal-markers.test.ts b/packages/cli/test/update-refusal-markers.test.ts
new file mode 100644
index 0000000..c15d53d
--- /dev/null
+++ b/packages/cli/test/update-refusal-markers.test.ts
@@ -0,0 +1,59 @@
+/**
+ * Drift guard for the server's self-update classifier.
+ *
+ * POST /api/version/update (packages/server/src/http/routes/version.ts) tells a refusal
+ * ("unsupported") apart from a successful run by matching `penguin update` output against
+ * REFUSAL_MARKERS — five English fragments duplicated from this package's i18n catalog.
+ * The server cannot import the CLI (the dependency runs the other way), so the fragments
+ * are hardcoded here too, on purpose: editing a refusal message without updating
+ * REFUSAL_MARKERS would silently misclassify refusals as "updated", and this test turns
+ * that drift into a loud failure at edit time. If it fails, change the marker in
+ * packages/server/src/http/routes/version.ts REFUSAL_MARKERS together with the string
+ * (or keep the fragment intact in the new wording).
+ */
+import { describe, expect, it } from "vitest";
+import { getMessages } from "../src/i18n.js";
+
+const en = getMessages("en").update;
+
+/**
+ * Keep in sync with REFUSAL_MARKERS in packages/server/src/http/routes/version.ts —
+ * one marker per refusal message, paired with the en output that must contain it.
+ * (`needsYes` / `cancelled` are not refusals the server can see: it always runs with
+ * `--yes`.) The argument values are arbitrary; markers must not depend on them.
+ */
+const MARKERS: ReadonlyArray<{ marker: string; name: string; message: string }> = [
+ {
+ marker: "runs from a source checkout",
+ name: "sourceCheckout",
+ message: en.sourceCheckout(),
+ },
+ {
+ marker: "Cannot tell how this penguin was installed",
+ name: "unknownInstall",
+ message: en.unknownInstall("/opt/penguin/cli.js"),
+ },
+ {
+ marker: "could not be identified",
+ name: "npmUnknownManager",
+ message: en.npmUnknownManager("/usr/lib/node_modules", "1.2.3"),
+ },
+ {
+ marker: "does not run on Windows",
+ name: "windowsUnsupported",
+ message: en.windowsUnsupported(),
+ },
+ {
+ marker: "cannot run your package manager for you",
+ name: "windowsGlobalInstall",
+ message: en.windowsGlobalInstall("npm install -g @prismshadow/penguin-cli@1.2.3"),
+ },
+];
+
+describe("update refusal markers (server classifier contract)", () => {
+ for (const { marker, name, message } of MARKERS) {
+ it(`en update.${name} still contains its server-side marker`, () => {
+ expect(message).toContain(marker);
+ });
+ }
+});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 4ddf735..c3d9aeb 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -52,3 +52,7 @@ export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } f
/** SDK version number. */
export const VERSION = "0.1.2";
+/** Release build date (UTC yyyy-mm-dd), stamped by the release workflow next to VERSION; null in a dev/source build. */
+export const BUILD_DATE: string | null = null;
+// Version-string helpers (shared by the CLI's `penguin update` and the server's update check).
+export { compareVersions, normalizeVersion } from "./internal/version.js";
diff --git a/packages/core/src/internal/version.ts b/packages/core/src/internal/version.ts
new file mode 100644
index 0000000..190ffd3
--- /dev/null
+++ b/packages/core/src/internal/version.ts
@@ -0,0 +1,41 @@
+/**
+ * Version-string helpers shared by everything that talks about releases: the CLI's
+ * `penguin update` command and the server's update-check endpoint (the web UI's update
+ * reminder). Moved here from the CLI so the server does not need a dependency on the CLI
+ * package; the barrel re-exports both functions.
+ */
+
+/** Strips a leading `v` so `v0.1.2` and `0.1.2` are the same input. */
+export function normalizeVersion(tag: string): string {
+ return tag.trim().replace(/^v/i, "");
+}
+
+/**
+ * Compares two dotted numeric versions: -1 / 0 / 1.
+ *
+ * Each dot-separated component is read with `Number.parseInt`, which takes the leading digits and
+ * ignores the rest: `1abc` is 1, and `2-rc1` is 2. A component with no leading digit at all, or
+ * one that is missing entirely, counts as 0 — which is the property that matters, because it means
+ * a malformed or truncated tag can never make an upgrade look available.
+ *
+ * The consequence, stated rather than papered over: suffixes are invisible here, so `0.1.2-rc1`
+ * compares *equal* to `0.1.2`. This project tags plain `vX.Y.Z` releases only, and the API this
+ * reads (`tag_name` from GitHub Releases) returns those tags, so the case does not arise; carrying
+ * a full semver precedence implementation — with its own numeric-vs-alphanumeric identifier rules
+ * — to handle tags we do not publish would be more code and more ways to be wrong. If pre-release
+ * tags are ever published, this has to become a real semver compare before the CLI's `--release`
+ * flag can target one.
+ */
+export function compareVersions(a: string, b: string): number {
+ const parse = (v: string) =>
+ normalizeVersion(v)
+ .split(".")
+ .map((n) => Number.parseInt(n, 10));
+ const [x, y] = [parse(a), parse(b)];
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
+ const l = Number.isFinite(x[i]) ? (x[i] as number) : 0;
+ const r = Number.isFinite(y[i]) ? (y[i] as number) : 0;
+ if (l !== r) return l < r ? -1 : 1;
+ }
+ return 0;
+}
diff --git a/packages/docs/content/configuration.en.md b/packages/docs/content/configuration.en.md
index 6101d6f..6d4590e 100644
--- a/packages/docs/content/configuration.en.md
+++ b/packages/docs/content/configuration.en.md
@@ -18,6 +18,7 @@ The CLI and the server automatically load a `.env` file from the working directo
| `PENGUIN_WEB_DIST` | Front-end static assets directory | the npm server package falls back to its bundled web-dist |
| `PENGUIN_PREVIEW_ORIGIN` | Origin that serves Workspace HTML previews, e.g. `https://preview.example.com` | unset — the loopback counterpart is derived per request |
| `PENGUIN_LANG` | CLI language (`en` / `zh`), set via `penguin config lang` | `en` |
+| `PENGUIN_UPDATE_CHECK` | `off` disables the web app's new-release check (the server's only outbound internet call) | enabled |
`PENGUIN_PREVIEW_ORIGIN` must differ from the app's origin by **hostname**, not just port: cookies ignore ports, so a second port would still share the session cookie. Leave it unset for local use — the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`, which needs no configuration and no DNS. Set it when the app is reached over a LAN address or a real domain; otherwise previews there fall back to a same-origin sandbox where `localStorage`, cookies and third-party embeds do not work. When you do set it on a real domain, keep the session cookie host-only (no `Domain=`), or a sibling subdomain shares it. An unparseable value is a startup error rather than a silent fallback.
diff --git a/packages/docs/content/configuration.zh.md b/packages/docs/content/configuration.zh.md
index aaa969d..03516ad 100644
--- a/packages/docs/content/configuration.zh.md
+++ b/packages/docs/content/configuration.zh.md
@@ -18,6 +18,7 @@ CLI 与服务端启动时会自动加载工作目录下的 `.env` 文件。
| `PENGUIN_WEB_DIST` | 前端静态资源目录 | npm 安装的服务端包回退到内置 web-dist |
| `PENGUIN_PREVIEW_ORIGIN` | 提供 Workspace HTML 预览的独立源,如 `https://preview.example.com` | 未设置,按请求推导回环对应名 |
| `PENGUIN_LANG` | CLI 语言(`en` / `zh`),用 `penguin config lang` 设置 | `en` |
+| `PENGUIN_UPDATE_CHECK` | 设为 `off` 关闭 Web 应用的新版本检查(服务端唯一的对外网络请求) | 开启 |
`PENGUIN_PREVIEW_ORIGIN` 必须与应用源在**主机名**上不同,只换端口不行:Cookie 不区分端口,换端口仍然共用会话 Cookie。本地使用不必配置——App 固定在规范主机 `localhost`,预览用 `127.0.0.1`,既不需要配置也不需要 DNS。经 LAN 地址或真实域名访问时才需要设置,否则那里的预览会回退到同源沙箱,`localStorage`、Cookie 与第三方 embed 都不可用。在真实域名上设置时,会话 Cookie 必须保持 host-only(不带 `Domain=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。
diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md
index 673577c..8d31594 100644
--- a/packages/docs/content/server-api.en.md
+++ b/packages/docs/content/server-api.en.md
@@ -66,6 +66,16 @@ curl -c cookies.txt -H "Content-Type: application/json" \
| POST | /api/admin/users/:userId/password | Reset a password (invalidates all of that user's login sessions) |
| DELETE | /api/admin/users/:userId | Delete a user |
+### Version and Self-Update
+
+| Method | Path | Description |
+| --- | --- | --- |
+| GET | /api/version | Running release identity: `{version, buildDate}` (`buildDate` is the running version's release date, stamped at build time — no network; null in a dev/source build or a release that predates the stamping) |
+| GET | /api/version/update-check | Compares the newest GitHub release with the running version: `{currentVersion, latestVersion, updateAvailable, releaseUrl, publishedAt, checkedAt, disabled?, error?}`; `?force=1` (the manual "check for updates" action) bypasses the TTL cache, and the outcome is cached as usual |
+| POST | /api/version/update | **Admin only.** Runs the CLI self-update (`penguin update --yes`) on the server host: `{status, output, needsRestart}` |
+
+`update-check` is the server's only outbound internet call and is strictly fail-soft: a failed lookup still returns 200 with `error` set (`network` / `rate_limited` / `bad_response`) and `latestVersion: null`, results are cached in memory (success 1 h, failure 10 min), and setting `PENGUIN_UPDATE_CHECK=off` disables the lookup entirely (`disabled: true`, no network call). The update `status` is `updated` (restart the service to run the new version), `failed`, or `unsupported` — the latter both when the server was not started via `penguin server|web` (`reason: "not_launched_via_cli"`) and when the CLI refuses (source checkout, unrecognized install layout, Windows); `output` carries the tail of the CLI's own output.
+
### Projects and Members
| Method | Path | Description |
diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md
index a631a5e..7ea8ecc 100644
--- a/packages/docs/content/server-api.zh.md
+++ b/packages/docs/content/server-api.zh.md
@@ -66,6 +66,16 @@ curl -c cookies.txt -H "Content-Type: application/json" \
| POST | /api/admin/users/:userId/password | 重置密码(该用户全部登录会话失效) |
| DELETE | /api/admin/users/:userId | 删除用户 |
+### 版本与在线更新
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | /api/version | 当前运行版本:`{version, buildDate}`(`buildDate` 是当前运行版本的发布日期,构建时打入、无需联网;开发/源码构建以及打入机制之前的发布版为 null) |
+| GET | /api/version/update-check | 对比 GitHub 最新 Release 与当前版本:`{currentVersion, latestVersion, updateAvailable, releaseUrl, publishedAt, checkedAt, disabled?, error?}`;`?force=1`(手动「检查更新」)绕过 TTL 缓存,结果照常写入缓存 |
+| POST | /api/version/update | **仅管理员。**在服务器上执行 CLI 在线更新(`penguin update --yes`):`{status, output, needsRestart}` |
+
+`update-check` 是服务端唯一的对外网络请求,并且严格失败兜底:查询失败仍返回 200,只是设置 `error`(`network` / `rate_limited` / `bad_response`)且 `latestVersion` 为 null;结果在内存中缓存(成功 1 小时、失败 10 分钟);设置 `PENGUIN_UPDATE_CHECK=off` 可完全关闭该查询(返回 `disabled: true`,不发起任何网络请求)。更新的 `status` 为 `updated`(需重启服务才能运行新版本)、`failed` 或 `unsupported` —— 后者包括服务不是通过 `penguin server|web` 启动(`reason: "not_launched_via_cli"`),以及 CLI 自身拒绝执行(源码运行、无法识别的安装方式、Windows);`output` 携带 CLI 输出的末尾片段。
+
### Project 与成员
| 方法 | 路径 | 说明 |
diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md
index cfc4fc7..73aeb0d 100644
--- a/packages/docs/content/web-app.en.md
+++ b/packages/docs/content/web-app.en.md
@@ -100,6 +100,10 @@ Read-only scoreboards per Benchmark: switch the metric (score / cost / duration)
Admin only: list and create users, reset passwords, and delete users (the built-in admin cannot be deleted).
+## Version and Updates
+
+The sidebar user menu carries a manual "Check for updates" action directly below "Change password"; the running version sits muted on the right of that row, and its release date — stamped into the build by the release workflow, displayed without any network access — appears as the row's localized "Last updated Jul 26"-style tooltip (dev builds and releases that predate the stamping, v0.1.2 and earlier, have no date). The new-chat page shows the same identity as a version line under the brand. The app checks GitHub for a newer release once the menu has first been opened, and immediately — bypassing the cached result — when the manual action is clicked; the latter reports "You're on the latest version" when nothing newer exists. When a newer release is found, a dot appears on the user button, the version displays gain a small superscript "New version available" badge (the draft-page badge links to the release), and the menu gains a release-notes link, plus an "Update now" action for admins that runs `penguin update` on the server (the data directory is untouched). The service must be restarted afterwards for the update to take effect. Set `PENGUIN_UPDATE_CHECK=off` to disable the update check entirely — see the [Configuration Reference](/configuration).
+
## Projects and Members
The sidebar provides a Project switcher and supports creating new Projects. Members have two roles, owner and member: owners manage membership and exclusively edit models, Vault, and Schedules, as well as perform deletions.
diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md
index 44fba67..27ba486 100644
--- a/packages/docs/content/web-app.zh.md
+++ b/packages/docs/content/web-app.zh.md
@@ -100,6 +100,10 @@ penguin web
仅管理员可见:列出与创建用户、重置密码、删除用户(内置 admin 不可删除)。
+## 版本与更新
+
+侧边栏用户菜单在「修改密码」下方提供手动「检查更新」按钮,当前运行版本号以浅色显示在该行右侧;其发布日期(由发布流程在构建时打入、直接显示、无需联网)以「最近更新日期 7 月 26 日」样式的悬浮提示展示(开发构建以及打入机制之前的发布版,v0.1.2 及以前,没有日期)。新对话页在品牌下方以版本行显示同样的信息。菜单首次打开后会向 GitHub 查询是否有新版本;点击「检查更新」则立即查询(绕过缓存结果),已是最新时以提示告知。发现新版本时,用户按钮上会出现提示圆点,版本显示处会出现「有新版本可用」上标小徽标(新对话页的徽标可跳转 Release 页面),菜单内提供更新说明链接;管理员还可点击"立即更新",在服务器上执行 `penguin update`(数据目录不受影响)。更新完成后需要重启服务才会生效。设置 `PENGUIN_UPDATE_CHECK=off` 可完全关闭新版本检查——见[配置参考](/configuration)。
+
## Project 与成员
侧边栏提供 Project 切换器,并支持创建新 Project。成员分为 Owner 与 Member 两种角色:Owner 负责成员管理,并独占模型、Vault、Schedule 的编辑以及各类删除操作。
diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts
index 8cd2588..fb420d3 100644
--- a/packages/server/src/api/types.ts
+++ b/packages/server/src/api/types.ts
@@ -1250,3 +1250,58 @@ export interface AgentSkillsResponse {
export interface SkillInstallRequest {
names: string[];
}
+
+// ---------------------------------------------------------------------------
+// Version and self-update
+// ---------------------------------------------------------------------------
+
+/** GET /api/version: the running server's release identity (from core's VERSION / BUILD_DATE). */
+export interface VersionResponse {
+ version: string;
+ /**
+ * The **running** version's release date (UTC yyyy-mm-dd), stamped into core's
+ * BUILD_DATE at build time by the release workflow — the web's "last updated" date
+ * needs no network. Null for a dev/source build and for releases that predate the
+ * stamping (v0.1.2 and earlier): the UI then shows the version alone.
+ */
+ buildDate: string | null;
+}
+
+/**
+ * GET /api/version/update-check: newest published release vs the running version.
+ * Always HTTP 200 (fail-soft): a lookup failure sets `error` and leaves `latestVersion`
+ * null rather than failing the request; results are cached server-side.
+ */
+export interface UpdateCheckResponse {
+ currentVersion: string;
+ /** Same as VersionResponse.buildDate: the running version's release date, stamped at build time. */
+ buildDate: string | null;
+ /** Newest published release (normalized, no leading `v`); null when the lookup failed or checks are disabled. */
+ latestVersion: string | null;
+ updateAvailable: boolean;
+ /** Release page of the newest release (for the "release notes" link). */
+ releaseUrl: string | null;
+ /** Publish timestamp of the newest release (ISO 8601). */
+ publishedAt: string | null;
+ /** When this result was produced (ISO 8601) — a cached result keeps its original timestamp. */
+ checkedAt: string;
+ /** Present (true) when update checks are turned off via PENGUIN_UPDATE_CHECK=off; no network call was made. */
+ disabled?: true;
+ /** Why the lookup failed: unreachable network / GitHub rate limit / unusable response. */
+ error?: "network" | "rate_limited" | "bad_response";
+}
+
+/**
+ * POST /api/version/update (admin only): runs the CLI self-update (`penguin update --yes`)
+ * on the server host. `unsupported` covers both a server not launched via the CLI and the
+ * CLI's own refusals (source checkout, unrecognized install layout, Windows).
+ */
+export interface UpdateRunResponse {
+ status: "updated" | "failed" | "unsupported";
+ /** Set when the server cannot run the CLI at all (started without `penguin server|web`). */
+ reason?: "not_launched_via_cli";
+ /** Tail of the update command's combined stdout+stderr (capped; empty when nothing ran). */
+ output: string;
+ /** True when the install changed (or was already current): restart the service to run the new version. */
+ needsRestart: boolean;
+}
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 70df0ba..5d35c84 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -47,6 +47,7 @@ import { agentConfigRoutes } from "./http/routes/agent-config.js";
import { agentTracesRoutes } from "./http/routes/agent-traces.js";
import { usageRoutes } from "./http/routes/usage.js";
import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js";
+import { versionRoutes } from "./http/routes/version.js";
import { ChannelHub } from "./runtime/channel.js";
import { ErrorRecorder } from "./runtime/error-recorder.js";
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
@@ -65,6 +66,7 @@ import { ProjectConfigService } from "./services/project-config-service.js";
import { ProjectService } from "./services/project-service.js";
import { SessionService } from "./services/session-service.js";
import { TraceService } from "./services/trace-service.js";
+import { UpdateCheckService } from "./services/update-check-service.js";
import { UsageService } from "./services/usage-service.js";
import { WorkspaceFilesService } from "./services/workspace-files-service.js";
import {
@@ -93,6 +95,8 @@ export interface AppDeps {
sessionService: SessionService;
traceService: TraceService;
usageService: UsageService;
+ /** GitHub latest-release lookup for the web UI's update reminder (cached, fail-soft). */
+ updateCheck: UpdateCheckService;
workspaceFiles: WorkspaceFilesService;
/** Signs/verifies short-lived Workspace preview tokens (separate preview origin). */
previewTokens: PreviewTokenSigner;
@@ -115,6 +119,8 @@ export interface BuildDepsOverrides {
loader?: SessionLoader;
/** Test double: Session title generator (avoids real LLM requests). */
titles?: TitleNotifier;
+ /** Test double: update-check service with a stubbed fetch/clock (avoids real network calls). */
+ updateCheck?: UpdateCheckService;
log?: (line: string) => void;
now?: () => Date;
}
@@ -151,6 +157,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
overrides.now ?? (() => new Date()),
);
+ const updateCheck =
+ overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
// Channel idle reclamation skips active Sessions (running/compacting can go a long time
// without a publish, e.g. while waiting for approval).
@@ -247,6 +255,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
sessionService,
traceService,
usageService,
+ updateCheck,
workspaceFiles,
previewTokens,
benchmarks,
@@ -331,6 +340,7 @@ export function createApp(deps: AppDeps): Hono {
const auth = authMiddleware(deps.authService);
app.use("/api/*", auth);
app.route("/api/me", meRoutes(deps));
+ app.route("/api/version", versionRoutes(deps));
app.route("/api/admin/users", adminUsersRoutes(deps));
app.route("/api/events", eventsRoutes(deps));
// Skill library listing: readable once logged in, not nested under a Project prefix.
diff --git a/packages/server/src/http/routes/version.ts b/packages/server/src/http/routes/version.ts
new file mode 100644
index 0000000..85deabe
--- /dev/null
+++ b/packages/server/src/http/routes/version.ts
@@ -0,0 +1,154 @@
+/**
+ * Version routes: the running release identity, the update-check reminder, and the
+ * admin-only self-update.
+ *
+ * GET /api/version -> {version, buildDate} from core's VERSION / BUILD_DATE
+ * GET /api/version/update-check -> UpdateCheckService (fail-soft, cached, opt-out env;
+ * ?force=1 bypasses the cache for the manual check)
+ * POST /api/version/update -> admin only: re-runs the CLI as `penguin update --yes`
+ *
+ * How the self-update works: `penguin server|web` exports PENGUIN_CLI_ENTRY (its own entry
+ * script path) before importing this server, and this route re-runs that script as
+ * `node update --yes` — the CLI's update command owns all install-kind detection,
+ * download and replacement logic (packages/cli/src/commands/update.ts). A server started
+ * any other way (tests, a custom embedding) has no CLI to run and reports "unsupported".
+ * SECURITY: the spawned argv is a fixed literal list; nothing from the request flows into
+ * the command, its arguments, or its environment.
+ *
+ * Interpreting the CLI's outcome: `penguin update` exits non-zero only when an upgrade was
+ * attempted and failed; refusals (source checkout, unrecognized layout, Windows) print a
+ * message and exit 0. Exit codes alone therefore cannot separate "updated" from
+ * "unsupported", so the spawn forces PENGUIN_LANG=en and classifies by the refusal
+ * messages' stable English fragments (packages/cli/src/i18n.ts, `update` section). That
+ * matching is reliable here because the spawned CLI and this server ship in lockstep from
+ * the same install — the strings can never be from a different release than this code.
+ */
+import { spawn } from "node:child_process";
+import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
+import { Hono } from "hono";
+import type { UpdateRunResponse, VersionResponse } from "../../api/types.js";
+import { HttpError } from "../errors.js";
+import type { AppEnv } from "../../auth/middleware.js";
+import type { AppDeps } from "../../app.js";
+
+/** Only the tail of the update output is kept (installer logs can be long). */
+const OUTPUT_CAP = 64 * 1024;
+/** The whole update (download + install) must finish within this window. */
+const UPDATE_TIMEOUT_MS = 10 * 60 * 1000;
+
+/**
+ * English fragments of the CLI refusal messages (packages/cli/src/i18n.ts `update.*`),
+ * one per refusal reason. `needsYes` / `cancelled` are omitted: `--yes` makes the
+ * confirmation gate proceed unconditionally, so neither can occur here.
+ */
+const REFUSAL_MARKERS = [
+ "runs from a source checkout", // sourceCheckout
+ "Cannot tell how this penguin was installed", // unknownInstall
+ "could not be identified", // npmUnknownManager
+ "does not run on Windows", // windowsUnsupported
+ "cannot run your package manager for you", // windowsGlobalInstall
+];
+
+/**
+ * Maps the finished CLI run to an UpdateRunResponse. Exported for unit tests (tests never
+ * spawn anything real). Exit 0 with a refusal message is "unsupported"; any other exit 0
+ * is "updated" — including the CLI's "already on the latest version", because that means
+ * the *install* is current and only a restart of this (older, in-memory) process is
+ * missing, which is exactly what needsRestart says.
+ */
+export function classifyUpdateRun(exitCode: number, output: string): UpdateRunResponse {
+ if (exitCode !== 0) return { status: "failed", output, needsRestart: false };
+ if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) {
+ return { status: "unsupported", output, needsRestart: false };
+ }
+ return { status: "updated", output, needsRestart: true };
+}
+
+/** Runs `node update --yes` to completion and classifies the outcome. */
+function runSelfUpdate(cliEntry: string): Promise {
+ return new Promise((resolve) => {
+ // PENGUIN_LANG=en pins the CLI's output language so classifyUpdateRun's markers match
+ // regardless of the deployment's configured CLI language.
+ const child = spawn(process.execPath, [cliEntry, "update", "--yes"], {
+ env: { ...process.env, PENGUIN_LANG: "en" },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let output = "";
+ const append = (chunk: Buffer) => {
+ output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP);
+ };
+ child.stdout.on("data", append);
+ child.stderr.on("data", append);
+
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ child.kill("SIGTERM");
+ // A SIGTERM-ignoring child would otherwise leave this request hanging forever.
+ setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
+ }, UPDATE_TIMEOUT_MS);
+ timer.unref();
+
+ child.on("error", (err) => {
+ clearTimeout(timer);
+ resolve({
+ status: "failed",
+ output: `${output}\n[failed to run the update command: ${err.message}]`.trim(),
+ needsRestart: false,
+ });
+ });
+ child.on("close", (code) => {
+ clearTimeout(timer);
+ if (timedOut) {
+ resolve({
+ status: "failed",
+ output:
+ `${output}\n[the update run was killed after ${UPDATE_TIMEOUT_MS / 60_000} minutes]`.trim(),
+ needsRestart: false,
+ });
+ return;
+ }
+ resolve(classifyUpdateRun(code ?? -1, output.trim()));
+ });
+ });
+}
+
+export function versionRoutes(deps: AppDeps): Hono {
+ const app = new Hono();
+
+ // Two admins clicking "update" at once must not race two installers over the same
+ // install dir: concurrent requests share the one in-flight run (and its result).
+ let inflightRun: Promise | null = null;
+
+ app.get("/", (c) => {
+ return c.json({ version: VERSION, buildDate: BUILD_DATE } satisfies VersionResponse);
+ });
+
+ app.get("/update-check", async (c) => {
+ // ?force=1 (the web's manual "check for updates" action) bypasses the TTL cache;
+ // see UpdateCheckService.check for why a user-initiated press-through is acceptable.
+ return c.json(await deps.updateCheck.check(c.req.query("force") === "1"));
+ });
+
+ app.post("/update", async (c) => {
+ if (!c.var.user.isAdmin) {
+ throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
+ }
+ const cliEntry = process.env.PENGUIN_CLI_ENTRY;
+ if (!cliEntry) {
+ return c.json({
+ status: "unsupported",
+ reason: "not_launched_via_cli",
+ output: "",
+ needsRestart: false,
+ } satisfies UpdateRunResponse);
+ }
+ inflightRun ??= runSelfUpdate(cliEntry).finally(() => {
+ inflightRun = null;
+ });
+ return c.json(await inflightRun);
+ });
+
+ return app;
+}
diff --git a/packages/server/src/services/update-check-service.ts b/packages/server/src/services/update-check-service.ts
new file mode 100644
index 0000000..aea3b42
--- /dev/null
+++ b/packages/server/src/services/update-check-service.ts
@@ -0,0 +1,141 @@
+/**
+ * Update-check service: resolves the newest published release from the GitHub Releases
+ * API and compares it with the running version (core's VERSION), for the web UI's
+ * update reminder (GET /api/version/update-check).
+ *
+ * This is the server's only outbound internet call, so it is strictly fail-soft: check()
+ * never throws and never surfaces an upstream failure as a 5xx — a failed lookup returns
+ * a normal response with `error` set and `latestVersion` null. Outcomes are cached
+ * in-memory (success for 1 hour, failure for 10 minutes) so a sidebar that opens on every
+ * page load cannot hammer GitHub's unauthenticated rate limit; concurrent requests share
+ * one in-flight lookup. `PENGUIN_UPDATE_CHECK=off` disables the lookup entirely (no
+ * network call), for air-gapped or privacy-sensitive deployments.
+ *
+ * The request mirrors the CLI's fetchLatestVersion (packages/cli/src/commands/update.ts):
+ * same endpoint, same accept / user-agent header shape, same 15s timeout — the server
+ * cannot import the CLI package (the dependency runs the other way), hence the small
+ * duplication. fetch and the clock are injectable so tests never touch the network.
+ */
+import { BUILD_DATE, VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
+import type { UpdateCheckResponse } from "../api/types.js";
+
+/** Repository the released artifacts come from (same slug as cli/update.ts's REPO_SLUG). */
+const REPO_SLUG = "Prism-Shadow/penguin-harness";
+/** Releases API endpoint for the newest published release. */
+export const LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
+
+/** How long a successful lookup is served from cache. */
+export const SUCCESS_TTL_MS = 60 * 60 * 1000;
+/** How long a failed lookup is served from cache (short: transient failures should heal). */
+export const FAILURE_TTL_MS = 10 * 60 * 1000;
+
+export interface UpdateCheckServiceOptions {
+ /** Test double for the network call (defaults to global fetch). */
+ fetchImpl?: typeof fetch;
+ /** Injectable clock for TTL determinism in tests. */
+ now?: () => Date;
+ /** Environment to read PENGUIN_UPDATE_CHECK from (defaults to process.env). */
+ env?: Record;
+}
+
+export class UpdateCheckService {
+ private readonly fetchImpl: typeof fetch;
+ private readonly now: () => Date;
+ private readonly env: Record;
+ private cached: { response: UpdateCheckResponse; expiresAt: number } | null = null;
+ private inflight: Promise | null = null;
+
+ constructor(options: UpdateCheckServiceOptions = {}) {
+ this.fetchImpl = options.fetchImpl ?? fetch;
+ this.now = options.now ?? (() => new Date());
+ this.env = options.env ?? process.env;
+ }
+
+ /**
+ * Never throws; never makes a network call when disabled or (without `force`) while
+ * the cache is fresh. `force` — the web's manual "check for updates" action — skips
+ * the freshness check and performs a real lookup even over a warm cache: the cache
+ * exists to shield GitHub's unauthenticated rate limit from *passive* checks fired
+ * by every sidebar open, and an explicit user click is rare enough to press through
+ * it. Everything else is unchanged: the opt-out stays authoritative (force never
+ * dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache
+ * (subsequent passive checks reuse it), and concurrent callers — forced or not —
+ * still share one in-flight lookup.
+ */
+ async check(force = false): Promise {
+ if (this.env["PENGUIN_UPDATE_CHECK"] === "off") {
+ return {
+ currentVersion: VERSION,
+ buildDate: BUILD_DATE,
+ latestVersion: null,
+ updateAvailable: false,
+ releaseUrl: null,
+ publishedAt: null,
+ checkedAt: this.now().toISOString(),
+ disabled: true,
+ };
+ }
+ if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) {
+ return this.cached.response;
+ }
+ this.inflight ??= this.lookup().finally(() => {
+ this.inflight = null;
+ });
+ return this.inflight;
+ }
+
+ /** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */
+ private async lookup(): Promise {
+ const response = await this.resolveLatest();
+ const ttl = response.error === undefined ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
+ this.cached = { response, expiresAt: this.now().getTime() + ttl };
+ return response;
+ }
+
+ /**
+ * Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or
+ * timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit
+ * GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body
+ * without a usable tag_name is "bad_response".
+ */
+ private async resolveLatest(): Promise {
+ const checkedAt = this.now().toISOString();
+ const base = { currentVersion: VERSION, buildDate: BUILD_DATE, checkedAt };
+ const failure = (error: "network" | "rate_limited" | "bad_response"): UpdateCheckResponse => ({
+ ...base,
+ latestVersion: null,
+ updateAvailable: false,
+ releaseUrl: null,
+ publishedAt: null,
+ error,
+ });
+
+ let res: Response;
+ try {
+ res = await this.fetchImpl(LATEST_RELEASE_API, {
+ headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" },
+ signal: AbortSignal.timeout(15_000),
+ });
+ } catch {
+ return failure("network");
+ }
+ if (res.status === 403 || res.status === 429) return failure("rate_limited");
+ if (!res.ok) return failure("bad_response");
+
+ let body: { tag_name?: unknown; html_url?: unknown; published_at?: unknown };
+ try {
+ body = (await res.json()) as typeof body;
+ } catch {
+ return failure("bad_response");
+ }
+ const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : "";
+ if (tag === "") return failure("bad_response");
+ return {
+ ...base,
+ latestVersion: tag,
+ updateAvailable: compareVersions(tag, VERSION) > 0,
+ releaseUrl: typeof body.html_url === "string" ? body.html_url : null,
+ publishedAt: typeof body.published_at === "string" ? body.published_at : null,
+ };
+ }
+}
diff --git a/packages/server/test/version.test.ts b/packages/server/test/version.test.ts
new file mode 100644
index 0000000..d43687f
--- /dev/null
+++ b/packages/server/test/version.test.ts
@@ -0,0 +1,298 @@
+/**
+ * Version routes and update-check service tests: response shapes, the fail-soft
+ * contract of /api/version/update-check (always 200, error field instead of 5xx),
+ * cache TTLs with an injected clock, the PENGUIN_UPDATE_CHECK=off opt-out, and the
+ * admin gate of POST /api/version/update. Nothing here touches the network or spawns
+ * a process: fetch is stubbed, and the update run is exercised only through its pure
+ * classifier and the "not launched via the CLI" early exit.
+ */
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { VERSION } from "@prismshadow/penguin-core";
+import type { UpdateCheckResponse, UpdateRunResponse, VersionResponse } from "../src/api/types.js";
+import {
+ FAILURE_TTL_MS,
+ SUCCESS_TTL_MS,
+ UpdateCheckService,
+} from "../src/services/update-check-service.js";
+import { classifyUpdateRun } from "../src/http/routes/version.js";
+import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
+import type { TestApp } from "./helpers.js";
+
+/** Counting fetch stub; the handler decides the outcome per call. */
+function makeFetch(handler: () => Response | Promise): {
+ impl: typeof fetch;
+ state: { calls: number };
+} {
+ const state = { calls: 0 };
+ const impl: typeof fetch = async () => {
+ state.calls += 1;
+ return handler();
+ };
+ return { impl, state };
+}
+
+function releaseResponse(tag: string): Response {
+ return new Response(
+ JSON.stringify({
+ tag_name: tag,
+ html_url: `https://github.com/Prism-Shadow/penguin-harness/releases/tag/${tag}`,
+ published_at: "2026-07-01T00:00:00Z",
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+}
+
+describe("GET /api/version", () => {
+ let t: TestApp;
+ beforeEach(async () => {
+ t = await createTestApp();
+ });
+ afterEach(async () => {
+ await t.cleanup();
+ });
+
+ it("requires auth and reports core's version identity (dev build: buildDate null)", async () => {
+ expect((await t.app.request("/api/version")).status).toBe(401);
+
+ const admin = await loginAdmin(t.app);
+ const res = await apiClient(t.app, admin.cookie).get("/api/version");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as VersionResponse;
+ expect(body).toEqual({ version: VERSION, buildDate: null });
+ });
+});
+
+describe("GET /api/version/update-check", () => {
+ let t: TestApp;
+ afterEach(async () => {
+ await t.cleanup();
+ });
+
+ it("reports a newer release with its URL and publish date", async () => {
+ const { impl } = makeFetch(() => releaseResponse("v99.0.0"));
+ t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) });
+ const admin = await loginAdmin(t.app);
+ const res = await apiClient(t.app, admin.cookie).get("/api/version/update-check");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as UpdateCheckResponse;
+ expect(body.currentVersion).toBe(VERSION);
+ expect(body.latestVersion).toBe("99.0.0");
+ expect(body.updateAvailable).toBe(true);
+ expect(body.releaseUrl).toBe(
+ "https://github.com/Prism-Shadow/penguin-harness/releases/tag/v99.0.0",
+ );
+ expect(body.publishedAt).toBe("2026-07-01T00:00:00Z");
+ expect(body.error).toBeUndefined();
+ expect(body.disabled).toBeUndefined();
+ });
+
+ it("fail-soft: an unreachable endpoint is HTTP 200 with error=network, not a 5xx", async () => {
+ const { impl } = makeFetch(() => {
+ throw new Error("getaddrinfo ENOTFOUND api.github.com");
+ });
+ t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) });
+ const admin = await loginAdmin(t.app);
+ const res = await apiClient(t.app, admin.cookie).get("/api/version/update-check");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as UpdateCheckResponse;
+ expect(body.error).toBe("network");
+ expect(body.latestVersion).toBeNull();
+ expect(body.updateAvailable).toBe(false);
+ expect(body.releaseUrl).toBeNull();
+ });
+
+ it("?force=1 (the manual check) reaches the service as a cache bypass", async () => {
+ const { impl, state } = makeFetch(() => releaseResponse("v99.0.0"));
+ t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) });
+ const admin = await loginAdmin(t.app);
+ const client = apiClient(t.app, admin.cookie);
+
+ // Warm the cache, then confirm a plain GET serves from it.
+ expect((await client.get("/api/version/update-check")).status).toBe(200);
+ expect((await client.get("/api/version/update-check")).status).toBe(200);
+ expect(state.calls).toBe(1);
+
+ const forced = await client.get("/api/version/update-check?force=1");
+ expect(forced.status).toBe(200);
+ expect(((await forced.json()) as UpdateCheckResponse).latestVersion).toBe("99.0.0");
+ expect(state.calls).toBe(2);
+ });
+});
+
+describe("UpdateCheckService", () => {
+ it("maps 403/429 to rate_limited and other bad statuses/bodies to bad_response", async () => {
+ for (const [make, expected] of [
+ [() => new Response("limited", { status: 403 }), "rate_limited"],
+ [() => new Response("limited", { status: 429 }), "rate_limited"],
+ [() => new Response("oops", { status: 500 }), "bad_response"],
+ [() => new Response("not json", { status: 200 }), "bad_response"],
+ [() => new Response(JSON.stringify({ name: "no tag" }), { status: 200 }), "bad_response"],
+ ] as const) {
+ const service = new UpdateCheckService({ fetchImpl: makeFetch(make).impl, env: {} });
+ const result = await service.check();
+ expect(result.error).toBe(expected);
+ expect(result.updateAvailable).toBe(false);
+ expect(result.latestVersion).toBeNull();
+ }
+ });
+
+ it("updateAvailable is false when the latest release is not newer", async () => {
+ const { impl } = makeFetch(() => releaseResponse(`v${VERSION}`));
+ const service = new UpdateCheckService({ fetchImpl: impl, env: {} });
+ const result = await service.check();
+ expect(result.error).toBeUndefined();
+ expect(result.latestVersion).toBe(VERSION);
+ expect(result.updateAvailable).toBe(false);
+ });
+
+ it("PENGUIN_UPDATE_CHECK=off disables the lookup without any network call", async () => {
+ const { impl, state } = makeFetch(() => releaseResponse("v99.0.0"));
+ const service = new UpdateCheckService({
+ fetchImpl: impl,
+ env: { PENGUIN_UPDATE_CHECK: "off" },
+ });
+ const result = await service.check();
+ expect(result.disabled).toBe(true);
+ expect(result.updateAvailable).toBe(false);
+ expect(result.latestVersion).toBeNull();
+ expect(state.calls).toBe(0);
+ });
+
+ it("caches a success for an hour and a failure for ten minutes", async () => {
+ let nowMs = 1_000_000_000;
+ let fail = false;
+ const { impl, state } = makeFetch(() => {
+ if (fail) throw new Error("down");
+ return releaseResponse("v99.0.0");
+ });
+ const service = new UpdateCheckService({
+ fetchImpl: impl,
+ env: {},
+ now: () => new Date(nowMs),
+ });
+
+ const first = await service.check();
+ expect(first.latestVersion).toBe("99.0.0");
+ expect(state.calls).toBe(1);
+
+ // Within the success TTL: served from cache, original checkedAt preserved.
+ nowMs += SUCCESS_TTL_MS - 1;
+ const cached = await service.check();
+ expect(state.calls).toBe(1);
+ expect(cached.checkedAt).toBe(first.checkedAt);
+
+ // Past the success TTL: refetched; the failure is itself cached, but only briefly.
+ nowMs += 2;
+ fail = true;
+ const failed = await service.check();
+ expect(state.calls).toBe(2);
+ expect(failed.error).toBe("network");
+
+ nowMs += FAILURE_TTL_MS - 1;
+ expect((await service.check()).error).toBe("network");
+ expect(state.calls).toBe(2);
+
+ nowMs += 2;
+ fail = false;
+ const healed = await service.check();
+ expect(state.calls).toBe(3);
+ expect(healed.error).toBeUndefined();
+ expect(healed.latestVersion).toBe("99.0.0");
+ });
+
+ it("force bypasses a warm cache and recaches the fresh outcome", async () => {
+ let tag = "v99.0.0";
+ const { impl, state } = makeFetch(() => releaseResponse(tag));
+ const service = new UpdateCheckService({ fetchImpl: impl, env: {} });
+
+ // Warm the cache; a passive check is then served from it.
+ expect((await service.check()).latestVersion).toBe("99.0.0");
+ expect((await service.check()).latestVersion).toBe("99.0.0");
+ expect(state.calls).toBe(1);
+
+ // A forced check refetches despite the fresh cache…
+ tag = "v100.0.0";
+ expect((await service.check(true)).latestVersion).toBe("100.0.0");
+ expect(state.calls).toBe(2);
+
+ // …and stores the outcome: the next passive check reuses it without a call.
+ expect((await service.check()).latestVersion).toBe("100.0.0");
+ expect(state.calls).toBe(2);
+ });
+
+ it("force never dials out when PENGUIN_UPDATE_CHECK=off (the opt-out stays authoritative)", async () => {
+ const { impl, state } = makeFetch(() => releaseResponse("v99.0.0"));
+ const service = new UpdateCheckService({
+ fetchImpl: impl,
+ env: { PENGUIN_UPDATE_CHECK: "off" },
+ });
+ const result = await service.check(true);
+ expect(result.disabled).toBe(true);
+ expect(state.calls).toBe(0);
+ });
+});
+
+describe("POST /api/version/update", () => {
+ let t: TestApp;
+ let savedEntry: string | undefined;
+ beforeEach(async () => {
+ savedEntry = process.env.PENGUIN_CLI_ENTRY;
+ delete process.env.PENGUIN_CLI_ENTRY;
+ t = await createTestApp();
+ });
+ afterEach(async () => {
+ if (savedEntry === undefined) delete process.env.PENGUIN_CLI_ENTRY;
+ else process.env.PENGUIN_CLI_ENTRY = savedEntry;
+ await t.cleanup();
+ });
+
+ it("is admin-only", async () => {
+ const user = await provisionUser(t.app, "regular_user");
+ const res = await apiClient(t.app, user.cookie).post("/api/version/update", {});
+ expect(res.status).toBe(403);
+ });
+
+ it("reports unsupported when the server was not launched via the CLI", async () => {
+ const admin = await loginAdmin(t.app);
+ const res = await apiClient(t.app, admin.cookie).post("/api/version/update", {});
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as UpdateRunResponse;
+ expect(body).toEqual({
+ status: "unsupported",
+ reason: "not_launched_via_cli",
+ output: "",
+ needsRestart: false,
+ });
+ });
+});
+
+describe("classifyUpdateRun", () => {
+ it("a non-zero exit is a failed upgrade attempt", () => {
+ const r = classifyUpdateRun(1, "Upgrade failed; the previous install was left in place.");
+ expect(r.status).toBe("failed");
+ expect(r.needsRestart).toBe(false);
+ });
+
+ it("a refusal (exit 0 with the CLI's refusal message) is unsupported", () => {
+ // Literal CLI copy (packages/cli/src/i18n.ts update.sourceCheckout / unknownInstall):
+ // the classifier matches fragments of these exact strings.
+ const source =
+ "This penguin runs from a source checkout, so there is nothing to download — update it with `git pull` and rebuild (`pnpm install && pnpm -r build`).";
+ expect(classifyUpdateRun(0, source).status).toBe("unsupported");
+ const unknown =
+ "Cannot tell how this penguin was installed (running from /opt/penguin/cli.js), so it will not be replaced. Re-install with the official installer, or upgrade with the package manager you used.";
+ expect(classifyUpdateRun(0, unknown).status).toBe("unsupported");
+ });
+
+ it("a clean exit without a refusal is updated and needs a restart (up-to-date included)", () => {
+ const done =
+ "Upgrade 0.1.2 -> 0.1.3\nPenguinHarness 0.1.3 installed. Run `penguin --version` in a new shell to confirm.";
+ expect(classifyUpdateRun(0, done)).toEqual({
+ status: "updated",
+ output: done,
+ needsRestart: true,
+ });
+ const upToDate = "Already on the latest version (0.1.3); nothing to do.";
+ expect(classifyUpdateRun(0, upToDate).status).toBe("updated");
+ });
+});
diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts
index 4053a5b..3912b19 100644
--- a/packages/web/src/api/endpoints.ts
+++ b/packages/web/src/api/endpoints.ts
@@ -60,10 +60,13 @@ import type {
TraceImportRequest,
TraceImportResponse,
UiPrefs,
+ UpdateCheckResponse,
+ UpdateRunResponse,
UsageGroupBy,
UsageResponse,
VaultResponse,
VaultUpdateRequest,
+ VersionResponse,
WorkspaceFilesResponse,
} from "@prismshadow/penguin-server/api";
import { apiFetch } from "./client";
@@ -496,3 +499,15 @@ export const importAgent = (projectId: string, agentId: string, body: AgentImpor
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/import`,
{ method: "POST", body },
);
+
+// Version & self-update ----------------------------------------------------------------
+
+export const getVersion = () => apiFetch("/api/version");
+
+/** `force` (the manual "check for updates" action) bypasses the server's TTL cache. */
+export const checkUpdate = (force = false) =>
+ apiFetch(`/api/version/update-check${force ? "?force=1" : ""}`);
+
+/** Admin only: runs `penguin update` on the server host (long request — up to 10 minutes). */
+export const runUpdate = () =>
+ apiFetch("/api/version/update", { method: "POST", body: {} });
diff --git a/packages/web/src/components/account/update-dialog.tsx b/packages/web/src/components/account/update-dialog.tsx
new file mode 100644
index 0000000..5503503
--- /dev/null
+++ b/packages/web/src/components/account/update-dialog.tsx
@@ -0,0 +1,103 @@
+/**
+ * Admin self-update dialog (opened from the sidebar user menu's update reminder):
+ * explains that the new release is downloaded into the install directory and that the
+ * service must be restarted afterwards, then runs POST /api/version/update and shows the
+ * outcome — the CLI's own output tail in a scrollable
for the failed/unsupported
+ * states, and a restart hint on success. Closing is blocked while the update runs (the
+ * request can take minutes; navigating away would just hide the result).
+ */
+import { useEffect, useState } from "react";
+import type { UpdateRunResponse } from "@prismshadow/penguin-server/api";
+import * as api from "../../api/endpoints";
+import { S } from "../../lib/strings";
+import { apiErrorText } from "../../lib/api-error";
+import { Button } from "../ui/button";
+import { Modal } from "../ui/modal";
+
+type Phase = "confirm" | "running" | "done";
+
+export function UpdateDialog({
+ open,
+ onClose,
+ latestVersion,
+}: {
+ open: boolean;
+ onClose: () => void;
+ latestVersion: string | null;
+}) {
+ const [phase, setPhase] = useState("confirm");
+ const [result, setResult] = useState(null);
+
+ useEffect(() => {
+ if (!open) return;
+ setPhase("confirm");
+ setResult(null);
+ }, [open]);
+
+ const run = async () => {
+ setPhase("running");
+ try {
+ setResult(await api.runUpdate());
+ } catch (e) {
+ // The request itself failed (e.g. the connection dropped mid-run): surface it the
+ // same way as a failed run, with the error text where the output tail would be.
+ setResult({ status: "failed", output: apiErrorText(e), needsRestart: false });
+ }
+ setPhase("done");
+ };
+
+ const statusLine =
+ result === null
+ ? null
+ : result.status === "updated"
+ ? { text: S.update.updated, className: "text-green-600 dark:text-green-400" }
+ : result.status === "unsupported"
+ ? { text: S.update.unsupported, className: "text-amber-600 dark:text-amber-400" }
+ : { text: S.update.failed, className: "text-red-600 dark:text-red-400" };
+
+ return (
+ undefined : onClose}
+ footer={
+ phase === "done" ? (
+
+ ) : (
+ <>
+
+
+ >
+ )
+ }
+ >
+ {phase === "done" && result !== null && statusLine !== null ? (
+
+
{statusLine.text}
+ {result.status === "updated" && (
+
{S.update.restartHint}
+ )}
+ {result.output !== "" && (
+
+ {result.output}
+
+ )}
+
+ ) : (
+
+ {latestVersion !== null && (
+
{S.update.newVersion(latestVersion)}
+ )}
+
{S.update.confirmBody}
+ {phase === "running" && (
+
{S.update.updating}
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx
index b468aeb..be5a754 100644
--- a/packages/web/src/components/layout/sidebar.tsx
+++ b/packages/web/src/components/layout/sidebar.tsx
@@ -25,6 +25,7 @@ import type {
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { S } from "../../lib/strings";
+import { formatMonthDay } from "../../lib/format";
import { apiErrorText } from "../../lib/api-error";
import { useAuth } from "../../state/auth";
import { useLocale } from "../../state/locale";
@@ -48,7 +49,7 @@ import { Dropdown } from "../ui/dropdown";
import { AgentAvatar } from "../ui/agent-avatar";
import { Chevron } from "../ui/chevron";
import { ChevronDown } from "../ui/icons";
-import { toastError } from "../ui/toast";
+import { toastError, toastInfo, toastSuccess } from "../ui/toast";
import { Truncated } from "../ui/truncated";
import { Badge } from "../ui/badge";
import { Modal } from "../ui/modal";
@@ -61,6 +62,8 @@ import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
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";
function Icon({ d, size = 16 }: { d: string; size?: number }) {
return (
@@ -114,6 +117,14 @@ const PIN_ICON =
const menuItemClass =
"block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800";
+/**
+ * Superscript "new version" pill on the version line (accent-colored, raised via
+ * align-super). Kept literally identical to the draft page's copy in
+ * features/chat/draft-view.tsx — the two surfaces must not drift apart.
+ */
+const versionBadgeClass =
+ "ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
+
/** Grouping mode of the Session list (persisted; Workspace is the default). */
type GroupMode = "workspace" | "agent";
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
@@ -190,7 +201,7 @@ export function Sidebar({
const { user, logout } = useAuth();
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
useTheme();
- const { lang, setLang } = useLocale();
+ const { lang, locale, setLang } = useLocale();
const {
projects,
currentProject,
@@ -220,6 +231,40 @@ export function Sidebar({
const [createProjectOpen, setCreateProjectOpen] = useState(false);
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
+ const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
+ // Version row + update reminder: nothing is fetched until the dropdown first opens.
+ const { version, update } = useVersionInfo(userOpen);
+ const updateAvailable = update?.updateAvailable === true;
+ // The running version's release date, stamped into core's BUILD_DATE at build time by
+ // the release workflow — displayed as-is, no network involved. Dev builds and releases
+ // that predate the stamping (v0.1.2 and earlier) carry null. Shown as the localized
+ // "last updated" tooltip on the check-for-updates row (the row itself stays uncluttered).
+ const versionDate = version?.buildDate ?? null;
+ /** Manual "check for updates" in flight (row disabled, busy label). */
+ const [updateChecking, setUpdateChecking] = useState(false);
+ /**
+ * 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).
+ */
+ 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);
+ } catch (e) {
+ toastError(apiErrorText(e));
+ } finally {
+ setUpdateChecking(false);
+ }
+ };
const currentProjectId = currentProject?.projectId ?? null;
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
@@ -958,8 +1003,17 @@ export function Sidebar({
onClick={() => setUserOpen(!userOpen)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
>
-
+
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
+ {/* Update reminder dot: only once the lazy check has actually run and found a
+ newer release. The border (sidebar background color) separates it from the
+ avatar for every accent — the neutral accent matches the avatar fill. */}
+ {updateAvailable && (
+
+ )}
{user?.userId}
{user?.isAdmin && (
@@ -990,6 +1044,41 @@ export function Sidebar({
+ {/* Update reminder: release-notes link, plus the self-update action for admins.
+ Only rendered after the lazy check found a newer release. */}
+ {update !== null && update.updateAvailable && update.latestVersion !== null && (
+
+ {/* Manual update check, directly below Change password (owner layout). The
+ running version sits muted on the right of the same row — no product-name
+ prefix — and the superscript new-version badge rides along there as a
+ passive indicator: a nested button/link inside this button row would be
+ invalid HTML, and whenever the badge shows, the clickable affordances
+ (release link / Update now) are already present in the reminder rows
+ above. The "last updated" date lives in the row tooltip, keeping the row
+ itself uncluttered. While checking, the label swaps to the busy text and
+ the right-side version stays put. Nothing is fetched until the menu first
+ opens; the version span appears once /api/version resolves. */}
+
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && (
+ );
+}
+
/** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */
const pillClass =
"flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " +
diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts
index 7e7814d..a520e65 100644
--- a/packages/web/src/lib/format.ts
+++ b/packages/web/src/lib/format.ts
@@ -136,6 +136,42 @@ export function formatDateTime(iso: string): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
+/** English month abbreviations for formatMonthDay (Intl-free — see the rationale there). */
+const EN_MONTHS = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+] as const;
+
+/**
+ * `yyyy-mm-dd` (or a full ISO timestamp — only the date part is read) → localized
+ * month + day, no year: en `Jul 26`, zh `7 月 26 日` (the version footer's "last
+ * updated" date, product-specified wording — the zh form keeps the CJK/numeral
+ * spacing the owner asked for, which `Intl` would drop). The fields are read
+ * straight from the string rather than via `new Date()` + local-zone formatting:
+ * the input is a UTC calendar date (core's stamped BUILD_DATE, a release's
+ * publish timestamp), and round-tripping it through the viewer's timezone would
+ * render the previous day west of UTC. Unparsable or out-of-range input returns
+ * unchanged (same convention as formatDateTime).
+ */
+export function formatMonthDay(iso: string, locale: "zh" | "en"): string {
+ const m = /^\d{4}-(\d{2})-(\d{2})(?:$|T)/.exec(iso);
+ if (!m) return iso;
+ const month = Number(m[1]);
+ const day = Number(m[2]);
+ if (month < 1 || month > 12 || day < 1 || day > 31) return iso;
+ return locale === "en" ? `${EN_MONTHS[month - 1]} ${day}` : `${month} 月 ${day} 日`;
+}
+
/**
* Millisecond timestamp → human-readable message time: en `Jul 2, 2:58 PM` /
* zh `7月2日 14:58`; returns an empty string for an invalid value.
diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts
index 42e8546..bdd74bd 100644
--- a/packages/web/src/lib/strings-en.ts
+++ b/packages/web/src/lib/strings-en.ts
@@ -54,6 +54,30 @@ export const en: Strings = {
} as Record,
},
+ /** Version footer, update reminder, and admin self-update in the sidebar user menu. */
+ update: {
+ /** Version-line date label; `date` is formatMonthDay output, e.g. "Last updated Jul 26". */
+ lastUpdated: (date: string) => `Last updated ${date}`,
+ /** Superscript badge on the version lines when the update check found a newer release. */
+ newVersionBadge: "New version available",
+ newVersion: (v: string) => `New version v${v} available`,
+ /** Manual check action in the sidebar user menu, with its busy label and toast outcomes. */
+ checkNow: "Check for updates",
+ checking: "Checking…",
+ upToDate: "You're on the latest version",
+ checkFailed: "Update check failed — try again later",
+ checkDisabled: "Update checks are disabled (PENGUIN_UPDATE_CHECK=off)",
+ releaseNotes: "Release notes",
+ updateNow: "Update now",
+ updating: "Updating…",
+ updated: "Update complete — restart the service to apply",
+ restartHint: "Restart by re-running penguin web (or penguin server) in a terminal",
+ failed: "Update failed",
+ unsupported: "This install cannot be updated from the web UI",
+ confirmBody:
+ "Downloads the latest release and installs it into the install directory on the server (the data directory is not touched). Restart the service afterwards for the update to take effect.",
+ },
+
common: {
save: "Save",
cancel: "Cancel",
diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts
index e2c2821..cb7d974 100644
--- a/packages/web/src/lib/strings.ts
+++ b/packages/web/src/lib/strings.ts
@@ -55,6 +55,30 @@ export const zh = {
} as Record,
},
+ /** Version footer, update reminder, and admin self-update in the sidebar user menu. */
+ update: {
+ /** Version-line date label (owner-specified wording); `date` is formatMonthDay output, e.g. 「最近更新日期 7 月 26 日」. */
+ lastUpdated: (date: string) => `最近更新日期 ${date}`,
+ /** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
+ newVersionBadge: "有新版本可用",
+ newVersion: (v: string) => `新版本 v${v} 可用`,
+ /** Manual check action in the sidebar user menu (owner request), with its busy label and toast outcomes. */
+ checkNow: "检查更新",
+ checking: "检查中…",
+ upToDate: "已是最新版本",
+ checkFailed: "检查更新失败,请稍后重试",
+ checkDisabled: "更新检查已关闭(PENGUIN_UPDATE_CHECK=off)",
+ releaseNotes: "更新说明",
+ updateNow: "立即更新",
+ updating: "更新中…",
+ updated: "更新完成,重启服务后生效",
+ restartHint: "在终端重新运行 penguin web(或 penguin server)即可完成重启",
+ failed: "更新失败",
+ unsupported: "当前安装方式不支持在线更新",
+ confirmBody:
+ "将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
+ },
+
common: {
save: "保存",
cancel: "取消",
diff --git a/packages/web/src/lib/use-version-info.ts b/packages/web/src/lib/use-version-info.ts
new file mode 100644
index 0000000..8b1ca0f
--- /dev/null
+++ b/packages/web/src/lib/use-version-info.ts
@@ -0,0 +1,113 @@
+/**
+ * Lazily fetched version identity + update check for the sidebar user menu.
+ *
+ * Neither request fires on app load: both start on the first activation (the first time
+ * the user opens the bottom dropdown) and the results are cached at module level for the
+ * rest of the browser session — a locale switch remounts the whole tree, and component
+ * state would refetch on every remount. Failures resolve to null and clear the shared
+ * promise so a later dropdown open retries; nothing is surfaced as an error (the footer
+ * simply shows nothing, and "no update known" hides the reminder). forceUpdateCheck is
+ * the one deliberate exception to the laziness: the user asked, so it refetches now and
+ * broadcasts the result to every mounted hook.
+ */
+import { useEffect, useState } from "react";
+import type { UpdateCheckResponse, VersionResponse } from "@prismshadow/penguin-server/api";
+import * as api from "../api/endpoints";
+
+let versionCache: VersionResponse | null = null;
+let versionPromise: Promise | null = null;
+let updateCache: UpdateCheckResponse | null = null;
+let updatePromise: Promise | null = null;
+
+/**
+ * Mounted hooks subscribe here so forceUpdateCheck (the sidebar's manual "check for
+ * updates" action) can push its fresh result to every consumer at once — the footer,
+ * the update dot, the reminder rows, and the draft page's version line all react
+ * without a remount. The lazy fetch path doesn't need this (each hook awaits the
+ * shared promise itself); only an out-of-band refresh does.
+ */
+const listeners = new Set<() => void>();
+
+export interface VersionInfo {
+ version: VersionResponse | null;
+ update: UpdateCheckResponse | null;
+}
+
+export function useVersionInfo(active: boolean): VersionInfo {
+ // Initial state comes from the module cache, so a remounted sidebar (locale switch)
+ // shows the version footer and the update dot immediately, without reopening anything.
+ const [version, setVersion] = useState(versionCache);
+ const [update, setUpdate] = useState(updateCache);
+
+ // Re-sync from the module cache whenever forceUpdateCheck pushes a fresh result.
+ useEffect(() => {
+ const sync = () => {
+ setVersion(versionCache);
+ setUpdate(updateCache);
+ };
+ listeners.add(sync);
+ return () => {
+ listeners.delete(sync);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!active) return;
+ let cancelled = false;
+
+ versionPromise ??= api.getVersion().then((res) => {
+ versionCache = res;
+ return res;
+ });
+ versionPromise
+ .then((res) => {
+ if (!cancelled) setVersion(res);
+ })
+ .catch(() => {
+ versionPromise = null;
+ });
+
+ updatePromise ??= api.checkUpdate().then((res) => {
+ updateCache = res;
+ return res;
+ });
+ updatePromise
+ .then((res) => {
+ if (!cancelled) setUpdate(res);
+ })
+ .catch(() => {
+ updatePromise = null;
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [active]);
+
+ return { version, update };
+}
+
+/**
+ * Forced re-check for the sidebar's manual "check for updates" action: asks the server
+ * to bypass its TTL cache (?force=1), replaces the module cache, and pushes the result
+ * to every mounted consumer. The shared promise is swapped in up front so consumers
+ * activating mid-flight await the fresh lookup instead of resurrecting a stale one.
+ * The update check itself stays fail-soft (a lookup failure resolves normally with
+ * `error` set); this rejects only when the request to our own server fails — then the
+ * shared promise is cleared so the passive path can retry, and the caller toasts.
+ */
+export async function forceUpdateCheck(): Promise {
+ const promise = api.checkUpdate(true).then((res) => {
+ updateCache = res;
+ return res;
+ });
+ updatePromise = promise;
+ try {
+ return await promise;
+ } catch (e) {
+ if (updatePromise === promise) updatePromise = null;
+ throw e;
+ } finally {
+ for (const notify of listeners) notify();
+ }
+}
diff --git a/packages/web/test/format.test.ts b/packages/web/test/format.test.ts
index 98bb964..72589df 100644
--- a/packages/web/test/format.test.ts
+++ b/packages/web/test/format.test.ts
@@ -8,6 +8,7 @@ import {
formatBytes,
formatDateTime,
formatMoney,
+ formatMonthDay,
formatPercent,
formatRelativeDate,
formatRelativeDays,
@@ -226,3 +227,25 @@ describe("formatRelativeDate (semantic update time on Skill cards)", () => {
expect(formatRelativeDate("", "en")).toBe("");
});
});
+
+describe("formatMonthDay (version-line 'last updated' date)", () => {
+ it("formats a date-only string per locale, matching the owner-specified wording", () => {
+ expect(formatMonthDay("2026-07-26", "en")).toBe("Jul 26");
+ expect(formatMonthDay("2026-07-26", "zh")).toBe("7 月 26 日");
+ expect(formatMonthDay("2026-01-05", "en")).toBe("Jan 5");
+ expect(formatMonthDay("2026-12-31", "zh")).toBe("12 月 31 日");
+ });
+
+ it("reads only the date part of a full ISO timestamp — no timezone round-trip that could shift a day", () => {
+ expect(formatMonthDay("2026-07-01T00:00:00Z", "en")).toBe("Jul 1");
+ expect(formatMonthDay("2026-05-05T12:00:00Z", "zh")).toBe("5 月 5 日");
+ });
+
+ it("returns unparsable or out-of-range input unchanged", () => {
+ expect(formatMonthDay("not-a-date", "en")).toBe("not-a-date");
+ expect(formatMonthDay("2026-7-26", "zh")).toBe("2026-7-26"); // not the zero-padded wire format
+ expect(formatMonthDay("2026-07-26x", "en")).toBe("2026-07-26x");
+ expect(formatMonthDay("2026-13-01", "en")).toBe("2026-13-01");
+ expect(formatMonthDay("2026-00-10", "zh")).toBe("2026-00-10");
+ });
+});