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 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-27 23:34:06 +08:00
committed by GitHub
parent 033d526f9f
commit e1141ca010
27 changed files with 1375 additions and 40 deletions
+10
View File
@@ -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")
+24 -1
View File
@@ -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 <entry> 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 <entry> 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 };
}
+6 -36
View File
@@ -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:
+19
View File
@@ -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();
});
});
@@ -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);
});
}
});
+4
View File
@@ -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";
+41
View File
@@ -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;
}
@@ -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.
@@ -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=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。
+10
View File
@@ -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 |
+10
View File
@@ -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 与成员
| 方法 | 路径 | 说明 |
+4
View File
@@ -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.
+4
View File
@@ -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 的编辑以及各类删除操作。
+55
View File
@@ -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;
}
+10
View File
@@ -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<AppEnv> {
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.
+154
View File
@@ -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 <entry> 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 <cliEntry> update --yes` to completion and classifies the outcome. */
function runSelfUpdate(cliEntry: string): Promise<UpdateRunResponse> {
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<AppEnv> {
const app = new Hono<AppEnv>();
// 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<UpdateRunResponse> | 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;
}
@@ -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<string, string | undefined>;
}
export class UpdateCheckService {
private readonly fetchImpl: typeof fetch;
private readonly now: () => Date;
private readonly env: Record<string, string | undefined>;
private cached: { response: UpdateCheckResponse; expiresAt: number } | null = null;
private inflight: Promise<UpdateCheckResponse> | 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<UpdateCheckResponse> {
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<UpdateCheckResponse> {
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<UpdateCheckResponse> {
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,
};
}
}
+298
View File
@@ -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<Response>): {
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");
});
});
+15
View File
@@ -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<VersionResponse>("/api/version");
/** `force` (the manual "check for updates" action) bypasses the server's TTL cache. */
export const checkUpdate = (force = false) =>
apiFetch<UpdateCheckResponse>(`/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<UpdateRunResponse>("/api/version/update", { method: "POST", body: {} });
@@ -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 <pre> 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<Phase>("confirm");
const [result, setResult] = useState<UpdateRunResponse | null>(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 (
<Modal
open={open}
title={S.update.updateNow}
onClose={phase === "running" ? () => undefined : onClose}
footer={
phase === "done" ? (
<Button onClick={onClose}>{S.common.close}</Button>
) : (
<>
<Button onClick={onClose} disabled={phase === "running"}>
{S.common.cancel}
</Button>
<Button variant="primary" disabled={phase === "running"} onClick={() => void run()}>
{phase === "running" ? S.update.updating : S.update.updateNow}
</Button>
</>
)
}
>
{phase === "done" && result !== null && statusLine !== null ? (
<div className="space-y-3">
<p className={`text-sm font-medium ${statusLine.className}`}>{statusLine.text}</p>
{result.status === "updated" && (
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.restartHint}</p>
)}
{result.output !== "" && (
<pre className="max-h-56 overflow-auto rounded-md bg-gray-100 p-3 text-xs leading-relaxed whitespace-pre-wrap text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{result.output}
</pre>
)}
</div>
) : (
<div className="space-y-2">
{latestVersion !== null && (
<p className="text-sm font-medium">{S.update.newVersion(latestVersion)}</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.confirmBody}</p>
{phase === "running" && (
<p className="text-sm text-gray-500 dark:text-gray-400">{S.update.updating}</p>
)}
</div>
)}
</Modal>
);
}
+131 -3
View File
@@ -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"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
{(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 && (
<span
aria-hidden
className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-gray-50 bg-[var(--accent-bg)] dark:border-gray-900"
/>
)}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{user?.userId}</span>
{user?.isAdmin && (
@@ -990,6 +1044,41 @@ export function Sidebar({
<Segmented options={langOptions} value={lang} onChange={setLang} />
</SettingRow>
</div>
{/* 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 && (
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
{update.releaseUrl !== null ? (
<a
href={update.releaseUrl}
target="_blank"
rel="noopener noreferrer"
title={S.update.releaseNotes}
className={`${menuItemClass} flex items-center gap-2 font-medium`}
>
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
{S.update.newVersion(update.latestVersion)}
</a>
) : (
<span className={`${menuItemClass} flex items-center gap-2 font-medium`}>
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
{S.update.newVersion(update.latestVersion)}
</span>
)}
{user?.isAdmin && (
<button
type="button"
className={menuItemClass}
onClick={() => {
setUserOpen(false);
setUpdateDialogOpen(true);
}}
>
{S.update.updateNow}
</button>
)}
</div>
)}
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
type="button"
@@ -1001,6 +1090,40 @@ export function Sidebar({
>
{S.account.changePassword}
</button>
{/* 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. */}
<button
type="button"
disabled={updateChecking}
onClick={() => void runUpdateCheck()}
{...(versionDate !== null
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
: {})}
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
>
<span>{updateChecking ? S.update.checking : S.update.checkNow}</span>
{version !== null && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}`}
{update !== null && update.updateAvailable && update.latestVersion !== null && (
<span
className={versionBadgeClass}
title={S.update.newVersion(update.latestVersion)}
>
{S.update.newVersionBadge}
</span>
)}
</span>
)}
</button>
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && (
<button
@@ -1032,6 +1155,11 @@ export function Sidebar({
open={changePasswordOpen}
onClose={() => setChangePasswordOpen(false)}
/>
<UpdateDialog
open={updateDialogOpen}
onClose={() => setUpdateDialogOpen(false)}
latestVersion={update?.latestVersion ?? null}
/>
<CreateProjectDialog
open={createProjectOpen}
@@ -40,8 +40,10 @@ 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";
import { agentDisplayName, useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { AgentAvatar } from "../../components/ui/agent-avatar";
@@ -49,6 +51,7 @@ import { Chevron } from "../../components/ui/chevron";
import { Dropdown } from "../../components/ui/dropdown";
import { PenguinLogo } from "../../components/ui/penguin-logo";
import { toastError } from "../../components/ui/toast";
import { useVersionInfo } from "../../lib/use-version-info";
import { ChatInput } from "./chat-input";
import { buildSkillsMessage } from "./skill-use";
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
@@ -510,6 +513,7 @@ export function DraftView({
{S.appName}
</h1>
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
<VersionLine />
</div>
<ChatInput
@@ -665,6 +669,57 @@ export function DraftView({
);
}
/**
* Superscript "new version" pill on the version line (accent-colored, raised via
* align-super). Kept literally identical to the sidebar footer's copy in
* components/layout/sidebar.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";
/**
* Quiet version line under the brand subtitle: `PenguinHarness vX.Y.Z · 最近更新日期
* 7 月 26 日` / `… · Last updated Jul 26`. The date is the running version's release
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
* dev builds and releases that predate the stamping (v0.1.2 and earlier) carry null
* and show the version alone. When the update check knows a newer release, a small
* superscript badge follows, linking to the release page (this surface's existing
* affordance; the sidebar's badge additionally offers admins the update dialog).
* Fetching starts on mount — useVersionInfo caches at module level, so after the first
* resolution anywhere in the app this renders instantly and never refetches. Nothing
* renders until the version resolves (no placeholder flicker under the brand).
*/
function VersionLine() {
const { locale } = useLocale();
const { version, update } = useVersionInfo(true);
if (version === null) return null;
const date = version.buildDate;
return (
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
{`PenguinHarness v${version.version}${
date !== null ? ` · ${S.update.lastUpdated(formatMonthDay(date, locale))}` : ""
}`}
{update !== null &&
update.updateAvailable &&
update.latestVersion !== null &&
(update.releaseUrl !== null ? (
<a
href={update.releaseUrl}
target="_blank"
rel="noopener noreferrer"
title={S.update.newVersion(update.latestVersion)}
aria-label={S.update.newVersion(update.latestVersion)}
className={versionBadgeClass}
>
{S.update.newVersionBadge}
</a>
) : (
<span className={versionBadgeClass}>{S.update.newVersionBadge}</span>
))}
</p>
);
}
/** 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 " +
+36
View File
@@ -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.
+24
View File
@@ -54,6 +54,30 @@ export const en: Strings = {
} as Record<string, string>,
},
/** 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",
+24
View File
@@ -55,6 +55,30 @@ export const zh = {
} as Record<string, string>,
},
/** 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: "取消",
+113
View File
@@ -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<VersionResponse> | null = null;
let updateCache: UpdateCheckResponse | null = null;
let updatePromise: Promise<UpdateCheckResponse> | 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<VersionResponse | null>(versionCache);
const [update, setUpdate] = useState<UpdateCheckResponse | null>(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<UpdateCheckResponse> {
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();
}
}
+23
View File
@@ -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");
});
});